1#![cfg_attr(not(feature = "pack-zip"), allow(dead_code, unused_imports))]
2
3pub mod sorx_compatibility;
4pub mod sorx_exposure;
5pub mod sorx_validation;
6pub mod validation_generator;
7
8pub use sorx_compatibility::{
9 ApiCompatibilityMode, SORX_COMPATIBILITY_SCHEMA, SorxCompatibilityError,
10 SorxCompatibilityManifest, SorxCompatibilityPackageRef, StateCompatibilityMode,
11 generate_sorx_compatibility_manifest,
12};
13pub use sorx_exposure::{
14 SORX_EXPOSURE_POLICY_SCHEMA, SorxEndpointExposurePolicy, SorxExposurePolicy,
15 SorxExposurePolicyError, generate_sorx_exposure_policy,
16};
17pub use sorx_validation::{
18 EndpointVisibility, SORX_VALIDATION_SCHEMA, SorxValidationError, SorxValidationManifest,
19 SorxValidationPackageRef, SorxValidationSuite, SorxValidationTest, sorx_validation_schema_json,
20};
21pub use validation_generator::{
22 SorxValidationGenerationInput, generate_sorx_validation_manifest,
23 generate_sorx_validation_manifest_from_ir,
24};
25
26use greentic_sorla_ir::{
27 AgentEndpointApprovalModeIr, AgentEndpointInputIr, AgentEndpointIr, AgentEndpointOutputIr,
28 AgentEndpointRiskIr, CanonicalIr, EntityLinkingIr, IrVersion, OntologyModelIr,
29 ProviderRequirementIr, RetrievalBindingsIr, SemanticAliasesIr, agent_tools_json,
30 canonical_cbor, canonical_hash_hex, inspect_ir, lower_package,
31};
32use greentic_sorla_lang::parser::parse_package;
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35use std::collections::{BTreeMap, BTreeSet};
36use std::fs;
37use std::io::{Cursor, Read, Seek, Write};
38use std::path::{Path, PathBuf};
39#[cfg(feature = "pack-zip")]
40use zip::write::SimpleFileOptions;
41#[cfg(feature = "pack-zip")]
42use zip::{CompressionMethod, ZipArchive, ZipWriter};
43
44pub const AGENT_GATEWAY_HANDOFF_SCHEMA: &str = "greentic.agent-gateway.handoff.v1";
45pub const OPENAPI_AGENT_OVERLAY_SCHEMA: &str = "greentic.openapi.agent-overlay.v1";
46pub const MCP_TOOLS_HANDOFF_SCHEMA: &str = "greentic.mcp.tools.handoff.v1";
47pub const AGENT_GATEWAY_HANDOFF_FILENAME: &str = "agent-gateway.json";
48pub const AGENT_ENDPOINTS_IR_CBOR_FILENAME: &str = "agent-endpoints.ir.cbor";
49pub const AGENT_OPENAPI_OVERLAY_FILENAME: &str = "agent-endpoints.openapi.overlay.yaml";
50pub const AGENT_ARAZZO_FILENAME: &str = "agent-workflows.arazzo.yaml";
51pub const MCP_TOOLS_FILENAME: &str = "mcp-tools.json";
52pub const LLMS_TXT_FRAGMENT_FILENAME: &str = "llms.txt.fragment";
53pub const EXECUTABLE_CONTRACT_FILENAME: &str = "executable-contract.json";
54pub const ONTOLOGY_GRAPH_SCHEMA: &str = "greentic.sorla.ontology.graph.v1";
55pub const ONTOLOGY_EXTENSION_ID: &str = "greentic.sorla.ontology.v1";
56pub const ONTOLOGY_GRAPH_FILENAME: &str = "ontology.graph.json";
57pub const ONTOLOGY_IR_CBOR_FILENAME: &str = "ontology.ir.cbor";
58pub const ONTOLOGY_SCHEMA_FILENAME: &str = "ontology.schema.json";
59pub const ONTOLOGY_GRAPH_PATH: &str = "assets/sorla/ontology.graph.json";
60pub const ONTOLOGY_IR_CBOR_PATH: &str = "assets/sorla/ontology.ir.cbor";
61pub const ONTOLOGY_SCHEMA_PATH: &str = "assets/sorla/ontology.schema.json";
62pub const RETRIEVAL_BINDINGS_SCHEMA: &str = "greentic.sorla.retrieval-bindings.v1";
63pub const RETRIEVAL_BINDINGS_FILENAME: &str = "retrieval-bindings.json";
64pub const RETRIEVAL_BINDINGS_IR_CBOR_FILENAME: &str = "retrieval-bindings.ir.cbor";
65pub const RETRIEVAL_BINDINGS_PATH: &str = "assets/sorla/retrieval-bindings.json";
66pub const RETRIEVAL_BINDINGS_IR_CBOR_PATH: &str = "assets/sorla/retrieval-bindings.ir.cbor";
67pub const DESIGNER_NODE_TYPES_SCHEMA: &str = "greentic.sorla.designer-node-types.v1";
68pub const DESIGNER_NODE_TYPES_FILENAME: &str = "designer-node-types.json";
69pub const DESIGNER_NODE_TYPES_PATH: &str = "assets/sorla/designer-node-types.json";
70pub const AGENT_ENDPOINT_ACTION_CATALOG_SCHEMA: &str =
71 "greentic.sorla.agent-endpoint-action-catalog.v1";
72pub const AGENT_ENDPOINT_ACTION_CATALOG_FILENAME: &str = "agent-endpoint-action-catalog.json";
73pub const AGENT_ENDPOINT_ACTION_CATALOG_PATH: &str =
74 "assets/sorla/agent-endpoint-action-catalog.json";
75pub const DEFAULT_DESIGNER_COMPONENT_REF: &str =
76 "oci://ghcr.io/greenticai/components/component-sorx-business:0.1.0";
77pub const DEFAULT_DESIGNER_COMPONENT_OPERATION: &str = "invoke_locked_action";
78pub const SORX_RUNTIME_EXTENSION_ID: &str = "greentic.sorx.runtime.v1";
79pub const START_SCHEMA_FILENAME: &str = "start.schema.json";
80pub const START_QUESTIONS_FILENAME: &str = "start.questions.cbor";
81pub const RUNTIME_TEMPLATE_FILENAME: &str = "runtime.template.yaml";
82pub const PROVIDER_BINDINGS_TEMPLATE_FILENAME: &str = "provider-bindings.template.yaml";
83pub const SORX_COMPATIBILITY_PATH: &str = "assets/sorx/compatibility.json";
84const SORX_COMPATIBILITY_ASSET: &str = "compatibility.json";
85pub const SORX_EXPOSURE_POLICY_PATH: &str = "assets/sorx/exposure-policy.json";
86const SORX_EXPOSURE_POLICY_ASSET: &str = "exposure-policy.json";
87pub const SORX_VALIDATION_MANIFEST_PATH: &str = "assets/sorx/tests/test-manifest.json";
88const SORX_VALIDATION_MANIFEST_ASSET: &str = "tests/test-manifest.json";
89
90const STABLE_PACK_TIMESTAMP: &str = "1970-01-01T00:00:00Z";
91
92#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
93pub struct HandoffManifest {
94 pub package_kind: &'static str,
95 pub ir_version: IrVersionView,
96 pub provider_repo: &'static str,
97 pub required_provider_categories: Vec<ProviderRequirementView>,
98 pub artifact_references: Vec<String>,
99}
100
101pub type PackageManifest = HandoffManifest;
102
103#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
104pub struct IrVersionView {
105 pub major: u16,
106 pub minor: u16,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
110pub struct ProviderRequirementView {
111 pub category: String,
112 pub capabilities: Vec<String>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ArtifactSet {
117 pub ir: CanonicalIr,
118 pub package_manifest: PackageManifest,
119 pub cbor_artifacts: BTreeMap<String, Vec<u8>>,
120 pub inspect_json: String,
121 pub agent_tools_json: String,
122 pub agent_exports: AgentExportSet,
123 pub executable_contract_json: String,
124 pub designer_node_types_json: String,
125 pub agent_endpoint_action_catalog_json: String,
126 pub ontology_artifacts: Option<OntologyArtifactSet>,
127 pub canonical_hash: String,
128}
129
130impl ArtifactSet {
131 pub fn handoff_manifest(&self) -> &HandoffManifest {
132 &self.package_manifest
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct AgentGatewayHandoffManifest {
138 pub schema: String,
139 pub package: AgentGatewayPackageRef,
140 pub endpoints: Vec<AgentGatewayEndpointRef>,
141 pub provider_contract: AgentGatewayProviderContract,
142 pub exports: AgentGatewayExports,
143 pub notes: Vec<String>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct AgentGatewayPackageRef {
148 pub name: String,
149 pub version: String,
150 pub ir_version: String,
151 pub ir_hash: String,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct AgentGatewayEndpointRef {
156 pub id: String,
157 pub title: String,
158 pub intent: String,
159 pub risk: String,
160 pub approval: String,
161 pub inputs: Vec<String>,
162 pub outputs: Vec<String>,
163 pub side_effects: Vec<String>,
164 pub exports: AgentGatewayEndpointExports,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct AgentGatewayEndpointExports {
169 pub openapi: bool,
170 pub arazzo: bool,
171 pub mcp: bool,
172 pub llms_txt: bool,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct AgentGatewayProviderContract {
177 pub categories: Vec<AgentGatewayProviderRequirement>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct AgentGatewayProviderRequirement {
182 pub category: String,
183 pub capabilities: Vec<String>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct AgentGatewayExports {
188 pub agent_gateway_json: bool,
189 pub openapi_overlay: bool,
190 pub arazzo: bool,
191 pub mcp_tools: bool,
192 pub llms_txt: bool,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct AgentExportSet {
197 pub agent_gateway_json: String,
198 pub openapi_overlay_yaml: Option<String>,
199 pub arazzo_yaml: Option<String>,
200 pub mcp_tools_json: Option<String>,
201 pub llms_txt: Option<String>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct OntologyArtifactSet {
206 pub ir_cbor: Vec<u8>,
207 pub graph_json: String,
208 pub schema_json: String,
209 pub ir_hash: String,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct SorlaGtpackOptions {
214 pub input_path: PathBuf,
215 pub name: String,
216 pub version: String,
217 pub out_path: PathBuf,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
221pub struct SorlaGtpackBuildSummary {
222 pub out_path: String,
223 pub name: String,
224 pub version: String,
225 pub sorla_package_name: String,
226 pub sorla_package_version: String,
227 pub ir_hash: String,
228 pub manifest_hash_sha256: String,
229 pub assets: Vec<String>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
233pub struct SorlaGtpackInspection {
234 pub path: String,
235 pub name: String,
236 pub version: String,
237 pub extension: String,
238 pub sorla_package_name: String,
239 pub sorla_package_version: String,
240 pub ir_hash: String,
241 pub assets: Vec<String>,
242 pub optional_artifacts: BTreeMap<String, bool>,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub validation: Option<SorlaGtpackValidationInspection>,
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub exposure_policy: Option<SorlaGtpackExposurePolicyInspection>,
247 #[serde(skip_serializing_if = "Option::is_none")]
248 pub compatibility: Option<SorlaGtpackCompatibilityInspection>,
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub ontology: Option<SorlaGtpackOntologyInspection>,
251 #[serde(skip_serializing_if = "Option::is_none")]
252 pub retrieval_bindings: Option<SorlaGtpackRetrievalInspection>,
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub designer_node_types: Option<SorlaGtpackDesignerNodeTypesInspection>,
255 #[serde(skip_serializing_if = "Option::is_none")]
256 pub agent_endpoint_action_catalog: Option<SorlaGtpackAgentEndpointActionCatalogInspection>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
260pub struct SorlaGtpackValidationInspection {
261 pub schema: String,
262 pub suite_count: usize,
263 pub test_count: usize,
264 pub promotion_requires: Vec<String>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
268pub struct SorlaGtpackExposurePolicyInspection {
269 pub default_visibility: EndpointVisibility,
270 pub public_candidate_endpoints: usize,
271 pub approval_required_endpoints: usize,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
275pub struct SorlaGtpackCompatibilityInspection {
276 pub api_mode: ApiCompatibilityMode,
277 pub state_mode: StateCompatibilityMode,
278 pub provider_requirement_count: usize,
279 pub migration_rule_count: usize,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
283pub struct SorlaGtpackOntologyInspection {
284 pub schema: String,
285 pub graph_schema: String,
286 pub concept_count: usize,
287 pub relationship_count: usize,
288 pub constraint_count: usize,
289 pub ir_hash: String,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293pub struct SorlaGtpackRetrievalInspection {
294 pub schema: String,
295 pub provider_count: usize,
296 pub scope_count: usize,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300pub struct SorlaGtpackDesignerNodeTypesInspection {
301 pub schema: String,
302 pub count: usize,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct SorlaGtpackAgentEndpointActionCatalogInspection {
307 pub schema: String,
308 pub count: usize,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct DesignerNodeTypesDocument {
313 pub schema: String,
314 pub package: DesignerNodeTypesPackageRef,
315 #[serde(rename = "nodeTypes")]
316 pub node_types: Vec<DesignerNodeType>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct DesignerNodeTypesPackageRef {
321 pub name: String,
322 pub version: String,
323 pub ir_hash: String,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327pub struct DesignerNodeType {
328 pub id: String,
329 pub version: String,
330 pub label: String,
331 pub description: String,
332 pub category: String,
333 pub binding: DesignerNodeBinding,
334 #[serde(rename = "configSchema")]
335 pub config_schema: serde_json::Value,
336 #[serde(rename = "inputSchema")]
337 pub input_schema: serde_json::Value,
338 #[serde(rename = "outputSchema")]
339 pub output_schema: serde_json::Value,
340 pub ui: DesignerNodeUi,
341 #[serde(rename = "defaultRouting")]
342 pub default_routing: DesignerNodeRouting,
343 pub metadata: DesignerNodeMetadata,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct DesignerNodeBinding {
348 pub kind: String,
349 pub component: DesignerComponentRef,
350 pub operation: String,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
354pub struct DesignerComponentRef {
355 #[serde(rename = "ref")]
356 pub reference: String,
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360pub struct DesignerNodeUi {
361 pub fields: Vec<DesignerNodeUiField>,
362 pub tags: Vec<String>,
363 #[serde(default, skip_serializing_if = "Vec::is_empty")]
364 pub aliases: Vec<String>,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct DesignerNodeUiField {
369 pub name: String,
370 pub label: String,
371 pub widget: String,
372 #[serde(rename = "displayOrder")]
373 pub display_order: u16,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377pub struct DesignerNodeRouting {
378 pub kind: String,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct DesignerNodeMetadata {
383 pub endpoint: DesignerEndpointRef,
384 pub risk: String,
385 pub approval: String,
386 #[serde(default, skip_serializing_if = "String::is_empty")]
387 pub intent: String,
388 pub side_effects: Vec<String>,
389 pub provider_requirements: Vec<ProviderRequirementIr>,
390 pub backing: DesignerNodeBacking,
391 pub exports: AgentGatewayEndpointExports,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct DesignerEndpointRef {
396 pub id: String,
397 pub version: String,
398 pub package: String,
399 pub contract_hash: String,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403pub struct DesignerNodeBacking {
404 pub actions: Vec<String>,
405 pub events: Vec<String>,
406 pub flows: Vec<String>,
407 pub policies: Vec<String>,
408 pub approvals: Vec<String>,
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct DesignerNodeTypeGenerationOptions {
413 pub component_ref: String,
414 pub operation: String,
415}
416
417impl Default for DesignerNodeTypeGenerationOptions {
418 fn default() -> Self {
419 Self {
420 component_ref: DEFAULT_DESIGNER_COMPONENT_REF.to_string(),
421 operation: DEFAULT_DESIGNER_COMPONENT_OPERATION.to_string(),
422 }
423 }
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427pub struct AgentEndpointActionCatalogDocument {
428 pub schema: String,
429 pub package: AgentEndpointActionCatalogPackageRef,
430 pub actions: Vec<AgentEndpointActionCatalogAction>,
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub struct AgentEndpointActionCatalogPackageRef {
435 pub name: String,
436 pub version: String,
437 pub ir_hash: String,
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct AgentEndpointActionCatalogAction {
442 pub id: String,
443 pub version: String,
444 pub label: String,
445 pub description: String,
446 pub intent: String,
447 pub endpoint_ref: DesignerEndpointRef,
448 pub input_schema: serde_json::Value,
449 pub output_schema: serde_json::Value,
450 pub risk: String,
451 pub approval: String,
452 pub side_effects: Vec<String>,
453 pub provider_requirements: Vec<ProviderRequirementIr>,
454 pub backing: DesignerNodeBacking,
455 pub design: AgentEndpointActionCatalogDesign,
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459pub struct AgentEndpointActionCatalogDesign {
460 pub aliases: Vec<String>,
461 pub tags: Vec<String>,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
465pub struct SorlaGtpackDoctorReport {
466 pub path: String,
467 pub status: String,
468 pub checked_assets: Vec<String>,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472struct SorlaPackManifest {
473 schema: String,
474 pack: SorlaPackIdentity,
475 created_at_utc: String,
476 extension: serde_json::Value,
477 assets: Vec<String>,
478}
479
480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481struct SorlaPackIdentity {
482 name: String,
483 version: String,
484 kind: String,
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488struct SorlaPackLock {
489 schema: String,
490 entries: BTreeMap<String, SorlaPackLockEntry>,
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
494struct SorlaPackLockEntry {
495 size: u64,
496 sha256: String,
497}
498
499pub fn scaffold_handoff_manifest() -> HandoffManifest {
500 let version = IrVersion::scaffold();
501 HandoffManifest {
502 package_kind: "sorla-package",
503 ir_version: IrVersionView {
504 major: version.major,
505 minor: version.minor,
506 },
507 provider_repo: "greentic-sorla-providers",
508 required_provider_categories: Vec::new(),
509 artifact_references: vec![
510 "model.cbor".to_string(),
511 "actions.cbor".to_string(),
512 "events.cbor".to_string(),
513 "projections.cbor".to_string(),
514 "policies.cbor".to_string(),
515 "approvals.cbor".to_string(),
516 "views.cbor".to_string(),
517 "external-sources.cbor".to_string(),
518 "compatibility.cbor".to_string(),
519 "provider-contract.cbor".to_string(),
520 "package-manifest.cbor".to_string(),
521 "launcher-handoff.cbor".to_string(),
522 "agent-tools.json".to_string(),
523 EXECUTABLE_CONTRACT_FILENAME.to_string(),
524 ],
525 }
526}
527
528pub fn scaffold_manifest() -> PackageManifest {
529 scaffold_handoff_manifest()
530}
531
532pub fn build_handoff_artifacts_from_yaml(input: &str) -> Result<ArtifactSet, String> {
533 let parsed = parse_package(input)?;
534 let ir = lower_package(&parsed.package);
535 let mut package_manifest = scaffold_handoff_manifest();
536 package_manifest.required_provider_categories = ir
537 .provider_contract
538 .categories
539 .iter()
540 .map(provider_view)
541 .collect();
542 let agent_exports = export_agent_artifacts(&ir);
543 package_manifest
544 .artifact_references
545 .push(AGENT_GATEWAY_HANDOFF_FILENAME.to_string());
546 if !ir.agent_endpoints.is_empty() {
547 package_manifest
548 .artifact_references
549 .push(AGENT_ENDPOINTS_IR_CBOR_FILENAME.to_string());
550 }
551 if agent_exports.openapi_overlay_yaml.is_some() {
552 package_manifest
553 .artifact_references
554 .push(AGENT_OPENAPI_OVERLAY_FILENAME.to_string());
555 }
556 if agent_exports.arazzo_yaml.is_some() {
557 package_manifest
558 .artifact_references
559 .push(AGENT_ARAZZO_FILENAME.to_string());
560 }
561 if agent_exports.mcp_tools_json.is_some() {
562 package_manifest
563 .artifact_references
564 .push(MCP_TOOLS_FILENAME.to_string());
565 }
566 if agent_exports.llms_txt.is_some() {
567 package_manifest
568 .artifact_references
569 .push(LLMS_TXT_FRAGMENT_FILENAME.to_string());
570 }
571 let ontology_artifacts = ontology_artifacts(&ir)?;
572 if ontology_artifacts.is_some() {
573 package_manifest
574 .artifact_references
575 .push(ONTOLOGY_GRAPH_FILENAME.to_string());
576 package_manifest
577 .artifact_references
578 .push(ONTOLOGY_IR_CBOR_FILENAME.to_string());
579 package_manifest
580 .artifact_references
581 .push(ONTOLOGY_SCHEMA_FILENAME.to_string());
582 }
583 if ir.retrieval_bindings.is_some() {
584 package_manifest
585 .artifact_references
586 .push(RETRIEVAL_BINDINGS_FILENAME.to_string());
587 package_manifest
588 .artifact_references
589 .push(RETRIEVAL_BINDINGS_IR_CBOR_FILENAME.to_string());
590 }
591 let designer_node_types =
592 generate_designer_node_types_from_ir(&ir, &DesignerNodeTypeGenerationOptions::default())?;
593 let designer_node_types_json =
594 serde_json::to_string_pretty(&designer_node_types).map_err(|err| err.to_string())?;
595 let action_catalog = generate_agent_endpoint_action_catalog_from_ir(&ir)?;
596 let agent_endpoint_action_catalog_json =
597 serde_json::to_string_pretty(&action_catalog).map_err(|err| err.to_string())?;
598 if !designer_node_types.node_types.is_empty() {
599 package_manifest
600 .artifact_references
601 .push(DESIGNER_NODE_TYPES_FILENAME.to_string());
602 package_manifest
603 .artifact_references
604 .push(AGENT_ENDPOINT_ACTION_CATALOG_FILENAME.to_string());
605 }
606
607 let mut cbor_artifacts = BTreeMap::new();
608 cbor_artifacts.insert("actions.cbor".to_string(), canonical_cbor(&ir.actions));
609 cbor_artifacts.insert("approvals.cbor".to_string(), canonical_cbor(&ir.approvals));
610 cbor_artifacts.insert(
611 "compatibility.cbor".to_string(),
612 canonical_cbor(&ir.compatibility),
613 );
614 cbor_artifacts.insert("events.cbor".to_string(), canonical_cbor(&ir.events));
615 cbor_artifacts.insert(
616 "external-sources.cbor".to_string(),
617 canonical_cbor(&ir.external_sources),
618 );
619 cbor_artifacts.insert("model.cbor".to_string(), canonical_cbor(&ir));
620 if !ir.agent_endpoints.is_empty() {
621 cbor_artifacts.insert(
622 AGENT_ENDPOINTS_IR_CBOR_FILENAME.to_string(),
623 canonical_cbor(&ir),
624 );
625 }
626 if let Some(ontology_artifacts) = &ontology_artifacts {
627 cbor_artifacts.insert(
628 ONTOLOGY_IR_CBOR_FILENAME.to_string(),
629 ontology_artifacts.ir_cbor.clone(),
630 );
631 }
632 if let Some(retrieval) = &ir.retrieval_bindings {
633 cbor_artifacts.insert(
634 RETRIEVAL_BINDINGS_IR_CBOR_FILENAME.to_string(),
635 canonical_cbor(retrieval),
636 );
637 }
638 cbor_artifacts.insert("policies.cbor".to_string(), canonical_cbor(&ir.policies));
639 cbor_artifacts.insert(
640 "projections.cbor".to_string(),
641 canonical_cbor(&ir.projections),
642 );
643 cbor_artifacts.insert(
644 "provider-contract.cbor".to_string(),
645 canonical_cbor(&ir.provider_contract),
646 );
647 cbor_artifacts.insert(
648 "package-manifest.cbor".to_string(),
649 canonical_cbor(&package_manifest),
650 );
651 cbor_artifacts.insert(
652 "launcher-handoff.cbor".to_string(),
653 canonical_cbor(&package_manifest),
654 );
655 cbor_artifacts.insert("views.cbor".to_string(), canonical_cbor(&ir.views));
656
657 let inspect_json = inspect_ir(&ir);
658 let agent_tools = agent_tools_json(&ir);
659 let executable_contract = executable_contract_json(&ir);
660 let canonical_hash = canonical_hash_hex(&ir);
661
662 Ok(ArtifactSet {
663 ir,
664 package_manifest,
665 cbor_artifacts,
666 inspect_json,
667 agent_tools_json: agent_tools,
668 agent_exports,
669 executable_contract_json: executable_contract,
670 designer_node_types_json,
671 agent_endpoint_action_catalog_json,
672 ontology_artifacts,
673 canonical_hash,
674 })
675}
676
677pub fn generate_designer_node_types_from_ir(
678 ir: &CanonicalIr,
679 options: &DesignerNodeTypeGenerationOptions,
680) -> Result<DesignerNodeTypesDocument, String> {
681 if options.component_ref.trim().is_empty() {
682 return Err("designer node type component_ref must not be empty".to_string());
683 }
684 if options.operation.trim().is_empty() {
685 return Err("designer node type operation must not be empty".to_string());
686 }
687 let ir_hash = canonical_hash_hex(ir);
688 let node_types = ir
689 .agent_endpoints
690 .iter()
691 .map(|endpoint| designer_node_type_for_endpoint(ir, endpoint, options, &ir_hash))
692 .collect::<Vec<_>>();
693 Ok(DesignerNodeTypesDocument {
694 schema: DESIGNER_NODE_TYPES_SCHEMA.to_string(),
695 package: DesignerNodeTypesPackageRef {
696 name: ir.package.name.clone(),
697 version: ir.package.version.clone(),
698 ir_hash,
699 },
700 node_types,
701 })
702}
703
704pub fn generate_agent_endpoint_action_catalog_from_ir(
705 ir: &CanonicalIr,
706) -> Result<AgentEndpointActionCatalogDocument, String> {
707 let ir_hash = canonical_hash_hex(ir);
708 let contract_hash = format!("sha256:{ir_hash}");
709 let actions = ir
710 .agent_endpoints
711 .iter()
712 .map(|endpoint| {
713 let description = endpoint
714 .description
715 .clone()
716 .unwrap_or_else(|| endpoint.intent.clone());
717 AgentEndpointActionCatalogAction {
718 id: endpoint.id.clone(),
719 version: ir.package.version.clone(),
720 label: endpoint.title.clone(),
721 description,
722 intent: endpoint.intent.clone(),
723 endpoint_ref: DesignerEndpointRef {
724 id: endpoint.id.clone(),
725 version: ir.package.version.clone(),
726 package: ir.package.name.clone(),
727 contract_hash: contract_hash.clone(),
728 },
729 input_schema: object_schema_value(&endpoint.inputs),
730 output_schema: output_object_schema_value(&endpoint.outputs),
731 risk: agent_endpoint_risk_label(&endpoint.risk).to_string(),
732 approval: agent_endpoint_approval_label(&endpoint.approval).to_string(),
733 side_effects: endpoint.side_effects.clone(),
734 provider_requirements: endpoint.provider_requirements.clone(),
735 backing: DesignerNodeBacking {
736 actions: endpoint.backing.actions.clone(),
737 events: endpoint.backing.events.clone(),
738 flows: endpoint.backing.flows.clone(),
739 policies: endpoint.backing.policies.clone(),
740 approvals: endpoint.backing.approvals.clone(),
741 },
742 design: AgentEndpointActionCatalogDesign {
743 aliases: design_aliases_for_endpoint(endpoint),
744 tags: vec!["sorla".to_string(), "agent-endpoint".to_string()],
745 },
746 }
747 })
748 .collect::<Vec<_>>();
749
750 Ok(AgentEndpointActionCatalogDocument {
751 schema: AGENT_ENDPOINT_ACTION_CATALOG_SCHEMA.to_string(),
752 package: AgentEndpointActionCatalogPackageRef {
753 name: ir.package.name.clone(),
754 version: ir.package.version.clone(),
755 ir_hash,
756 },
757 actions,
758 })
759}
760
761fn designer_node_type_for_endpoint(
762 ir: &CanonicalIr,
763 endpoint: &AgentEndpointIr,
764 options: &DesignerNodeTypeGenerationOptions,
765 ir_hash: &str,
766) -> DesignerNodeType {
767 let endpoint_ref = serde_json::json!({
768 "id": endpoint.id,
769 "version": ir.package.version,
770 "package": ir.package.name,
771 "contract_hash": format!("sha256:{ir_hash}")
772 });
773 DesignerNodeType {
774 id: format!("sorla.agent-endpoint.{}", endpoint.id),
775 version: ir.package.version.clone(),
776 label: endpoint.title.clone(),
777 description: endpoint
778 .description
779 .clone()
780 .unwrap_or_else(|| endpoint.intent.clone()),
781 category: "System of Record".to_string(),
782 binding: DesignerNodeBinding {
783 kind: "component".to_string(),
784 component: DesignerComponentRef {
785 reference: options.component_ref.clone(),
786 },
787 operation: options.operation.clone(),
788 },
789 config_schema: serde_json::json!({
790 "type": "object",
791 "required": ["endpoint_ref"],
792 "properties": {
793 "endpoint_ref": {
794 "const": endpoint_ref
795 }
796 },
797 "additionalProperties": false
798 }),
799 input_schema: object_schema_value(&endpoint.inputs),
800 output_schema: output_object_schema_value(&endpoint.outputs),
801 ui: DesignerNodeUi {
802 fields: endpoint
803 .inputs
804 .iter()
805 .enumerate()
806 .map(|(index, input)| DesignerNodeUiField {
807 name: input.name.clone(),
808 label: label_from_identifier(&input.name),
809 widget: widget_for_input(input),
810 display_order: ((index + 1) * 10) as u16,
811 })
812 .collect(),
813 tags: vec!["sorla".to_string(), "agent-endpoint".to_string()],
814 aliases: design_aliases_for_endpoint(endpoint),
815 },
816 default_routing: DesignerNodeRouting {
817 kind: "out".to_string(),
818 },
819 metadata: DesignerNodeMetadata {
820 endpoint: DesignerEndpointRef {
821 id: endpoint.id.clone(),
822 version: ir.package.version.clone(),
823 package: ir.package.name.clone(),
824 contract_hash: format!("sha256:{ir_hash}"),
825 },
826 risk: agent_endpoint_risk_label(&endpoint.risk).to_string(),
827 approval: agent_endpoint_approval_label(&endpoint.approval).to_string(),
828 intent: endpoint.intent.clone(),
829 side_effects: endpoint.side_effects.clone(),
830 provider_requirements: endpoint.provider_requirements.clone(),
831 backing: DesignerNodeBacking {
832 actions: endpoint.backing.actions.clone(),
833 events: endpoint.backing.events.clone(),
834 flows: endpoint.backing.flows.clone(),
835 policies: endpoint.backing.policies.clone(),
836 approvals: endpoint.backing.approvals.clone(),
837 },
838 exports: AgentGatewayEndpointExports {
839 openapi: endpoint.agent_visibility.openapi,
840 arazzo: endpoint.agent_visibility.arazzo,
841 mcp: endpoint.agent_visibility.mcp,
842 llms_txt: endpoint.agent_visibility.llms_txt,
843 },
844 },
845 }
846}
847
848fn output_object_schema_value(outputs: &[AgentEndpointOutputIr]) -> serde_json::Value {
849 let properties = outputs
850 .iter()
851 .map(|output| {
852 let mut property = serde_json::Map::new();
853 property.insert(
854 "type".to_string(),
855 serde_json::Value::String(output.type_name.clone()),
856 );
857 if let Some(description) = &output.description {
858 property.insert(
859 "description".to_string(),
860 serde_json::Value::String(description.clone()),
861 );
862 }
863 (output.name.clone(), serde_json::Value::Object(property))
864 })
865 .collect::<serde_json::Map<_, _>>();
866
867 serde_json::json!({
868 "type": "object",
869 "properties": properties
870 })
871}
872
873fn label_from_identifier(identifier: &str) -> String {
874 identifier
875 .split(['_', '-'])
876 .filter(|part| !part.is_empty())
877 .map(|part| {
878 let mut chars = part.chars();
879 match chars.next() {
880 Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
881 None => String::new(),
882 }
883 })
884 .collect::<Vec<_>>()
885 .join(" ")
886}
887
888fn widget_for_input(input: &AgentEndpointInputIr) -> String {
889 if !input.enum_values.is_empty() {
890 return "select".to_string();
891 }
892 match input.type_name.as_str() {
893 "bool" | "boolean" => "checkbox",
894 "number" | "float" | "double" | "decimal" | "integer" | "int" => "number",
895 _ => "text",
896 }
897 .to_string()
898}
899
900fn design_aliases_for_endpoint(endpoint: &AgentEndpointIr) -> Vec<String> {
901 let mut aliases = BTreeSet::new();
902 aliases.insert(endpoint.id.replace(['_', '-'], " "));
903 aliases.insert(endpoint.title.to_ascii_lowercase());
904 aliases.into_iter().collect()
905}
906
907fn ontology_artifacts(ir: &CanonicalIr) -> Result<Option<OntologyArtifactSet>, String> {
908 let Some(ontology) = &ir.ontology else {
909 return Ok(None);
910 };
911
912 let ir_cbor = canonical_cbor(ontology);
913 let ir_hash = sha256_hex(&ir_cbor);
914 let graph = ontology_graph_json(ir, ontology, &ir_hash);
915 let schema = ontology_schema_json();
916
917 Ok(Some(OntologyArtifactSet {
918 ir_cbor,
919 graph_json: serde_json::to_string_pretty(&graph).map_err(|err| err.to_string())?,
920 schema_json: serde_json::to_string_pretty(&schema).map_err(|err| err.to_string())?,
921 ir_hash,
922 }))
923}
924
925fn ontology_graph_json(
926 ir: &CanonicalIr,
927 ontology: &OntologyModelIr,
928 ir_hash: &str,
929) -> serde_json::Value {
930 serde_json::json!({
931 "schema": ONTOLOGY_GRAPH_SCHEMA,
932 "package": {
933 "name": ir.package.name.clone(),
934 "version": ir.package.version.clone(),
935 },
936 "ir_hash": ir_hash,
937 "concepts": ontology.concepts.clone(),
938 "relationships": ontology.relationships.clone(),
939 "constraints": ontology.constraints.clone(),
940 "semantic_aliases": ontology.semantic_aliases.clone(),
941 "entity_linking": ontology.entity_linking.clone(),
942 "indexes": {
943 "concepts_by_id": true,
944 "relationships_by_id": true
945 }
946 })
947}
948
949pub fn ontology_schema_json() -> serde_json::Value {
950 serde_json::json!({
951 "$schema": "https://json-schema.org/draft/2020-12/schema",
952 "$id": ONTOLOGY_EXTENSION_ID,
953 "title": "SoRLa ontology v1",
954 "type": "object",
955 "required": ["schema", "concepts", "relationships", "semantic_aliases", "entity_linking"],
956 "properties": {
957 "schema": { "const": ONTOLOGY_EXTENSION_ID },
958 "concepts": { "type": "array" },
959 "relationships": { "type": "array" },
960 "constraints": { "type": "array" },
961 "semantic_aliases": { "type": "object" },
962 "entity_linking": { "type": "object" }
963 },
964 "additionalProperties": false
965 })
966}
967
968pub fn retrieval_bindings_schema_json() -> serde_json::Value {
969 serde_json::json!({
970 "$schema": "https://json-schema.org/draft/2020-12/schema",
971 "$id": RETRIEVAL_BINDINGS_SCHEMA,
972 "title": "SoRLa retrieval bindings v1",
973 "type": "object",
974 "additionalProperties": false,
975 "required": ["schema", "providers", "scopes"],
976 "properties": {
977 "schema": { "const": RETRIEVAL_BINDINGS_SCHEMA },
978 "providers": {
979 "type": "array",
980 "items": {
981 "type": "object",
982 "additionalProperties": false,
983 "required": ["id", "category", "required_capabilities"],
984 "properties": {
985 "id": { "type": "string", "minLength": 1 },
986 "category": { "type": "string", "minLength": 1 },
987 "required_capabilities": {
988 "type": "array",
989 "items": { "type": "string", "minLength": 1 }
990 }
991 }
992 }
993 },
994 "scopes": {
995 "type": "array",
996 "items": {
997 "type": "object",
998 "additionalProperties": false,
999 "required": ["id", "applies_to", "provider"],
1000 "properties": {
1001 "id": { "type": "string", "minLength": 1 },
1002 "applies_to": {
1003 "type": "object",
1004 "additionalProperties": false,
1005 "properties": {
1006 "concept": { "type": "string", "minLength": 1 },
1007 "relationship": { "type": "string", "minLength": 1 }
1008 }
1009 },
1010 "provider": { "type": "string", "minLength": 1 },
1011 "filters": { "type": "object" },
1012 "permission": {
1013 "type": "string",
1014 "enum": ["inherit", "public-metadata-only", "requires-policy"]
1015 }
1016 }
1017 }
1018 }
1019 }
1020 })
1021}
1022
1023#[cfg(feature = "pack-zip")]
1024pub fn build_sorla_gtpack(options: &SorlaGtpackOptions) -> Result<SorlaGtpackBuildSummary, String> {
1025 let yaml = fs::read_to_string(&options.input_path).map_err(|err| {
1026 format!(
1027 "failed to read SoRLa input {}: {err}",
1028 options.input_path.display()
1029 )
1030 })?;
1031 let artifacts = build_artifacts_from_yaml(&yaml)?;
1032 build_sorla_gtpack_from_artifacts(options, artifacts)
1033}
1034
1035#[cfg(feature = "pack-zip")]
1036fn build_sorla_gtpack_from_artifacts(
1037 options: &SorlaGtpackOptions,
1038 artifacts: ArtifactSet,
1039) -> Result<SorlaGtpackBuildSummary, String> {
1040 semver::Version::parse(&options.version)
1041 .map_err(|err| format!("invalid pack version `{}`: {err}", options.version))?;
1042 if options.name.trim().is_empty() {
1043 return Err("pack name must not be empty".to_string());
1044 }
1045
1046 let mut sorx_assets = sorx_startup_assets(&artifacts.ir);
1047 let sorx_startup_asset_names = sorx_assets.keys().cloned().collect();
1048 let validation_manifest = generate_sorx_validation_manifest_from_ir(
1049 &artifacts.ir,
1050 Some(&artifacts.canonical_hash),
1051 sorx_startup_asset_names,
1052 );
1053 validation_manifest
1054 .validate_static()
1055 .map_err(|err| err.to_string())?;
1056 sorx_assets.insert(
1057 SORX_VALIDATION_MANIFEST_ASSET.to_string(),
1058 serde_json::to_vec_pretty(&validation_manifest).map_err(|err| err.to_string())?,
1059 );
1060 let exposure_policy = generate_sorx_exposure_policy(&artifacts.ir.agent_endpoints);
1061 let known_endpoint_ids = artifacts
1062 .ir
1063 .agent_endpoints
1064 .iter()
1065 .map(|endpoint| endpoint.id.as_str())
1066 .collect();
1067 exposure_policy
1068 .validate_static(&known_endpoint_ids)
1069 .map_err(|err| err.to_string())?;
1070 sorx_assets.insert(
1071 SORX_EXPOSURE_POLICY_ASSET.to_string(),
1072 serde_json::to_vec_pretty(&exposure_policy).map_err(|err| err.to_string())?,
1073 );
1074 let compatibility_manifest =
1075 generate_sorx_compatibility_manifest(&artifacts.ir, Some(&artifacts.canonical_hash));
1076 compatibility_manifest
1077 .validate_static()
1078 .map_err(|err| err.to_string())?;
1079 sorx_assets.insert(
1080 SORX_COMPATIBILITY_ASSET.to_string(),
1081 serde_json::to_vec_pretty(&compatibility_manifest).map_err(|err| err.to_string())?,
1082 );
1083 let extension = sorx_runtime_extension_value(&artifacts, &sorx_assets);
1084
1085 let mut entries: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1086 let mut asset_paths = Vec::new();
1087 let mut cbor_artifacts: Vec<_> = artifacts.cbor_artifacts.iter().collect();
1088 cbor_artifacts.sort_by_key(|(name, _)| *name);
1089 for (name, bytes) in cbor_artifacts {
1090 insert_pack_asset(
1091 &mut entries,
1092 &mut asset_paths,
1093 format!("assets/sorla/{name}"),
1094 bytes.clone(),
1095 );
1096 }
1097 if let Some(ontology) = &artifacts.ontology_artifacts {
1098 insert_pack_asset(
1099 &mut entries,
1100 &mut asset_paths,
1101 ONTOLOGY_GRAPH_PATH.to_string(),
1102 ontology.graph_json.as_bytes().to_vec(),
1103 );
1104 insert_pack_asset(
1105 &mut entries,
1106 &mut asset_paths,
1107 ONTOLOGY_SCHEMA_PATH.to_string(),
1108 ontology.schema_json.as_bytes().to_vec(),
1109 );
1110 }
1111 if let Some(retrieval) = &artifacts.ir.retrieval_bindings {
1112 insert_pack_asset(
1113 &mut entries,
1114 &mut asset_paths,
1115 RETRIEVAL_BINDINGS_PATH.to_string(),
1116 serde_json::to_vec_pretty(retrieval).map_err(|err| err.to_string())?,
1117 );
1118 }
1119
1120 insert_pack_asset(
1121 &mut entries,
1122 &mut asset_paths,
1123 format!("assets/sorla/{AGENT_GATEWAY_HANDOFF_FILENAME}"),
1124 artifacts
1125 .agent_exports
1126 .agent_gateway_json
1127 .as_bytes()
1128 .to_vec(),
1129 );
1130 if let Some(openapi) = &artifacts.agent_exports.openapi_overlay_yaml {
1131 insert_pack_asset(
1132 &mut entries,
1133 &mut asset_paths,
1134 format!("assets/sorla/{AGENT_OPENAPI_OVERLAY_FILENAME}"),
1135 openapi.as_bytes().to_vec(),
1136 );
1137 }
1138 if let Some(arazzo) = &artifacts.agent_exports.arazzo_yaml {
1139 insert_pack_asset(
1140 &mut entries,
1141 &mut asset_paths,
1142 format!("assets/sorla/{AGENT_ARAZZO_FILENAME}"),
1143 arazzo.as_bytes().to_vec(),
1144 );
1145 }
1146 if let Some(mcp_tools) = &artifacts.agent_exports.mcp_tools_json {
1147 insert_pack_asset(
1148 &mut entries,
1149 &mut asset_paths,
1150 format!("assets/sorla/{MCP_TOOLS_FILENAME}"),
1151 mcp_tools.as_bytes().to_vec(),
1152 );
1153 }
1154 if let Some(llms_txt) = &artifacts.agent_exports.llms_txt {
1155 insert_pack_asset(
1156 &mut entries,
1157 &mut asset_paths,
1158 format!("assets/sorla/{LLMS_TXT_FRAGMENT_FILENAME}"),
1159 llms_txt.as_bytes().to_vec(),
1160 );
1161 }
1162 insert_pack_asset(
1163 &mut entries,
1164 &mut asset_paths,
1165 format!("assets/sorla/{EXECUTABLE_CONTRACT_FILENAME}"),
1166 artifacts.executable_contract_json.as_bytes().to_vec(),
1167 );
1168 if !artifacts.ir.agent_endpoints.is_empty() {
1169 insert_pack_asset(
1170 &mut entries,
1171 &mut asset_paths,
1172 DESIGNER_NODE_TYPES_PATH.to_string(),
1173 artifacts.designer_node_types_json.as_bytes().to_vec(),
1174 );
1175 insert_pack_asset(
1176 &mut entries,
1177 &mut asset_paths,
1178 AGENT_ENDPOINT_ACTION_CATALOG_PATH.to_string(),
1179 artifacts
1180 .agent_endpoint_action_catalog_json
1181 .as_bytes()
1182 .to_vec(),
1183 );
1184 }
1185
1186 for (name, bytes) in &sorx_assets {
1187 insert_pack_asset(
1188 &mut entries,
1189 &mut asset_paths,
1190 format!("assets/sorx/{name}"),
1191 bytes.clone(),
1192 );
1193 }
1194
1195 asset_paths.sort();
1196 asset_paths.dedup();
1197
1198 let manifest = SorlaPackManifest {
1199 schema: "greentic.gtpack.manifest.sorla.v1".to_string(),
1200 pack: SorlaPackIdentity {
1201 name: options.name.clone(),
1202 version: options.version.clone(),
1203 kind: "application".to_string(),
1204 },
1205 created_at_utc: STABLE_PACK_TIMESTAMP.to_string(),
1206 extension,
1207 assets: asset_paths.clone(),
1208 };
1209 let pack_cbor = canonical_cbor(&manifest);
1210 entries.insert("pack.cbor".to_string(), pack_cbor.clone());
1211 entries.insert("manifest.cbor".to_string(), pack_cbor.clone());
1212 entries.insert(
1213 "manifest.json".to_string(),
1214 serde_json::to_string_pretty(&manifest)
1215 .expect("pack manifest should serialize")
1216 .into_bytes(),
1217 );
1218 let lock = pack_lock_for_entries(&entries);
1219 let lock_bytes = canonical_cbor(&lock);
1220 entries.insert("pack.lock.cbor".to_string(), lock_bytes);
1221
1222 write_zip_entries(&options.out_path, entries)?;
1223
1224 Ok(SorlaGtpackBuildSummary {
1225 out_path: options.out_path.display().to_string(),
1226 name: options.name.clone(),
1227 version: options.version.clone(),
1228 sorla_package_name: artifacts.ir.package.name,
1229 sorla_package_version: artifacts.ir.package.version,
1230 ir_hash: artifacts.canonical_hash,
1231 manifest_hash_sha256: sha256_hex(&pack_cbor),
1232 assets: asset_paths,
1233 })
1234}
1235
1236#[cfg(feature = "pack-zip")]
1237fn insert_pack_asset(
1238 entries: &mut BTreeMap<String, Vec<u8>>,
1239 asset_paths: &mut Vec<String>,
1240 path: String,
1241 bytes: Vec<u8>,
1242) {
1243 asset_paths.push(path.clone());
1244 entries.insert(path, bytes);
1245}
1246
1247#[cfg(feature = "pack-zip")]
1248fn sorx_startup_assets(ir: &CanonicalIr) -> BTreeMap<String, Vec<u8>> {
1249 let mut assets = BTreeMap::new();
1250 let schema = serde_json::json!({
1251 "schema": "greentic.sorx.start.answers.v1",
1252 "title": format!("{} Sorx startup answers", ir.package.name),
1253 "required": [
1254 "tenant.tenant_id",
1255 "server.bind",
1256 "server.public_base_url",
1257 "providers.store.kind",
1258 "providers.store.config_ref",
1259 "policy.approvals.high",
1260 "audit.sink"
1261 ],
1262 "example": sorx_startup_example()
1263 });
1264 assets.insert(
1265 START_SCHEMA_FILENAME.to_string(),
1266 serde_json::to_string_pretty(&schema)
1267 .expect("startup schema should serialize")
1268 .into_bytes(),
1269 );
1270
1271 let questions = serde_json::json!({
1272 "schema": "greentic.sorx.start.questions.v1",
1273 "questions": [
1274 {"id": "tenant.tenant_id", "kind": "text", "required": true},
1275 {"id": "tenant.environment", "kind": "text", "required": false, "default": "local"},
1276 {"id": "server.bind", "kind": "text", "required": true, "default": "127.0.0.1:8787"},
1277 {"id": "server.public_base_url", "kind": "text", "required": true, "default": "http://127.0.0.1:8787"},
1278 {"id": "mcp.enabled", "kind": "boolean", "required": false, "default": true},
1279 {"id": "mcp.bind", "kind": "text", "required": false, "default": "127.0.0.1:8790"},
1280 {"id": "providers.store.kind", "kind": "single-select", "required": true, "choices": ["foundationdb"]},
1281 {"id": "providers.store.config_ref", "kind": "text", "required": true, "default": "providers.foundationdb.local"},
1282 {"id": "policy.approvals.high", "kind": "single-select", "required": true, "choices": ["require_approval", "deny"]},
1283 {"id": "audit.sink", "kind": "single-select", "required": true, "choices": ["stdout"]}
1284 ]
1285 });
1286 assets.insert(
1287 START_QUESTIONS_FILENAME.to_string(),
1288 canonical_cbor(&questions),
1289 );
1290
1291 assets.insert(
1292 RUNTIME_TEMPLATE_FILENAME.to_string(),
1293 runtime_template_yaml(ir).into_bytes(),
1294 );
1295 assets.insert(
1296 PROVIDER_BINDINGS_TEMPLATE_FILENAME.to_string(),
1297 provider_bindings_template_yaml().into_bytes(),
1298 );
1299 assets
1300}
1301
1302#[cfg(feature = "pack-zip")]
1303fn sorx_startup_example() -> serde_json::Value {
1304 serde_json::json!({
1305 "tenant": {
1306 "tenant_id": "demo-landlord",
1307 "environment": "local"
1308 },
1309 "server": {
1310 "bind": "127.0.0.1:8787",
1311 "public_base_url": "http://127.0.0.1:8787"
1312 },
1313 "mcp": {
1314 "enabled": true,
1315 "bind": "127.0.0.1:8790"
1316 },
1317 "providers": {
1318 "store": {
1319 "kind": "foundationdb",
1320 "config_ref": "providers.foundationdb.local"
1321 }
1322 },
1323 "policy": {
1324 "approvals": {
1325 "low": "auto",
1326 "medium": "auto",
1327 "high": "require_approval",
1328 "critical": "deny"
1329 }
1330 },
1331 "audit": {
1332 "sink": "stdout"
1333 }
1334 })
1335}
1336
1337#[cfg(feature = "pack-zip")]
1338fn runtime_template_yaml(ir: &CanonicalIr) -> String {
1339 format!(
1340 "schema: greentic.sorx.runtime.template.v1\npackage:\n name: {}\n version: {}\nruntime:\n tenant_id: ${{tenant.tenant_id}}\n environment: ${{tenant.environment}}\nserver:\n bind: ${{server.bind}}\n public_base_url: ${{server.public_base_url}}\nmcp:\n enabled: ${{mcp.enabled}}\n bind: ${{mcp.bind}}\nproviders:\n store:\n kind: ${{providers.store.kind}}\n config_ref: ${{providers.store.config_ref}}\npolicy:\n approvals:\n low: ${{policy.approvals.low}}\n medium: ${{policy.approvals.medium}}\n high: ${{policy.approvals.high}}\n critical: ${{policy.approvals.critical}}\naudit:\n sink: ${{audit.sink}}\n",
1341 ir.package.name, ir.package.version
1342 )
1343}
1344
1345#[cfg(feature = "pack-zip")]
1346fn provider_bindings_template_yaml() -> String {
1347 "schema: greentic.sorx.provider-bindings.template.v1\nproviders:\n foundationdb:\n local:\n kind: foundationdb\n config_ref: providers.foundationdb.local\n tenant_prefix: ${tenant.tenant_id}\n".to_string()
1348}
1349
1350#[cfg(feature = "pack-zip")]
1351fn sorx_runtime_extension_value(
1352 artifacts: &ArtifactSet,
1353 sorx_assets: &BTreeMap<String, Vec<u8>>,
1354) -> serde_json::Value {
1355 let mut sorla = serde_json::Map::new();
1356 sorla.insert(
1357 "model".to_string(),
1358 serde_json::json!("assets/sorla/model.cbor"),
1359 );
1360 sorla.insert(
1361 "package_manifest".to_string(),
1362 serde_json::json!("assets/sorla/package-manifest.cbor"),
1363 );
1364 sorla.insert(
1365 "executable_contract".to_string(),
1366 serde_json::json!(format!("assets/sorla/{EXECUTABLE_CONTRACT_FILENAME}")),
1367 );
1368 sorla.insert(
1369 "agent_gateway".to_string(),
1370 serde_json::json!(format!("assets/sorla/{AGENT_GATEWAY_HANDOFF_FILENAME}")),
1371 );
1372 if artifacts
1373 .cbor_artifacts
1374 .contains_key(AGENT_ENDPOINTS_IR_CBOR_FILENAME)
1375 {
1376 sorla.insert(
1377 "agent_endpoints_ir".to_string(),
1378 serde_json::json!(format!("assets/sorla/{AGENT_ENDPOINTS_IR_CBOR_FILENAME}")),
1379 );
1380 }
1381 if artifacts.agent_exports.openapi_overlay_yaml.is_some() {
1382 sorla.insert(
1383 "openapi_overlay".to_string(),
1384 serde_json::json!(format!("assets/sorla/{AGENT_OPENAPI_OVERLAY_FILENAME}")),
1385 );
1386 }
1387 if artifacts.agent_exports.arazzo_yaml.is_some() {
1388 sorla.insert(
1389 "arazzo".to_string(),
1390 serde_json::json!(format!("assets/sorla/{AGENT_ARAZZO_FILENAME}")),
1391 );
1392 }
1393 if artifacts.agent_exports.mcp_tools_json.is_some() {
1394 sorla.insert(
1395 "mcp_tools".to_string(),
1396 serde_json::json!(format!("assets/sorla/{MCP_TOOLS_FILENAME}")),
1397 );
1398 }
1399 if artifacts.agent_exports.llms_txt.is_some() {
1400 sorla.insert(
1401 "llms_fragment".to_string(),
1402 serde_json::json!(format!("assets/sorla/{LLMS_TXT_FRAGMENT_FILENAME}")),
1403 );
1404 }
1405 if artifacts.ontology_artifacts.is_some() {
1406 sorla.insert(
1407 "ontology".to_string(),
1408 serde_json::json!({
1409 "schema": ONTOLOGY_EXTENSION_ID,
1410 "graph": ONTOLOGY_GRAPH_PATH,
1411 "ir": ONTOLOGY_IR_CBOR_PATH,
1412 "json_schema": ONTOLOGY_SCHEMA_PATH
1413 }),
1414 );
1415 }
1416 if artifacts.ir.retrieval_bindings.is_some() {
1417 sorla.insert(
1418 "retrieval_bindings".to_string(),
1419 serde_json::json!({
1420 "schema": RETRIEVAL_BINDINGS_SCHEMA,
1421 "json": RETRIEVAL_BINDINGS_PATH,
1422 "ir": RETRIEVAL_BINDINGS_IR_CBOR_PATH
1423 }),
1424 );
1425 }
1426 if !artifacts.ir.agent_endpoints.is_empty() {
1427 sorla.insert(
1428 "designer_node_types".to_string(),
1429 serde_json::json!({
1430 "schema": DESIGNER_NODE_TYPES_SCHEMA,
1431 "json": DESIGNER_NODE_TYPES_PATH
1432 }),
1433 );
1434 sorla.insert(
1435 "agent_endpoint_action_catalog".to_string(),
1436 serde_json::json!({
1437 "schema": AGENT_ENDPOINT_ACTION_CATALOG_SCHEMA,
1438 "json": AGENT_ENDPOINT_ACTION_CATALOG_PATH
1439 }),
1440 );
1441 }
1442
1443 let mut sorx = sorx_assets
1444 .keys()
1445 .filter(|name| {
1446 name.as_str() != SORX_VALIDATION_MANIFEST_ASSET
1447 && name.as_str() != SORX_EXPOSURE_POLICY_ASSET
1448 && name.as_str() != SORX_COMPATIBILITY_ASSET
1449 })
1450 .map(|name| {
1451 let key = name
1452 .strip_suffix(".json")
1453 .or_else(|| name.strip_suffix(".cbor"))
1454 .or_else(|| name.strip_suffix(".yaml"))
1455 .unwrap_or(name)
1456 .replace('.', "_");
1457 (
1458 key,
1459 serde_json::Value::String(format!("assets/sorx/{name}")),
1460 )
1461 })
1462 .collect::<serde_json::Map<_, _>>();
1463 if sorx_assets.contains_key(SORX_VALIDATION_MANIFEST_ASSET) {
1464 sorx.insert(
1465 "validation_manifest".to_string(),
1466 serde_json::Value::String(SORX_VALIDATION_MANIFEST_PATH.to_string()),
1467 );
1468 }
1469 if sorx_assets.contains_key(SORX_EXPOSURE_POLICY_ASSET) {
1470 sorx.insert(
1471 "exposure_policy".to_string(),
1472 serde_json::Value::String(SORX_EXPOSURE_POLICY_PATH.to_string()),
1473 );
1474 }
1475 if sorx_assets.contains_key(SORX_COMPATIBILITY_ASSET) {
1476 sorx.insert(
1477 "compatibility".to_string(),
1478 serde_json::Value::String(SORX_COMPATIBILITY_PATH.to_string()),
1479 );
1480 }
1481
1482 serde_json::json!({
1483 "extension": SORX_RUNTIME_EXTENSION_ID,
1484 "sorla": sorla,
1485 "sorx": sorx
1486 })
1487}
1488
1489#[cfg(feature = "pack-zip")]
1490fn pack_lock_for_entries(entries: &BTreeMap<String, Vec<u8>>) -> SorlaPackLock {
1491 SorlaPackLock {
1492 schema: "greentic.gtpack.lock.sorla.v1".to_string(),
1493 entries: entries
1494 .iter()
1495 .map(|(path, bytes)| {
1496 (
1497 path.clone(),
1498 SorlaPackLockEntry {
1499 size: bytes.len() as u64,
1500 sha256: sha256_hex(bytes),
1501 },
1502 )
1503 })
1504 .collect(),
1505 }
1506}
1507
1508fn sha256_hex(bytes: &[u8]) -> String {
1509 let digest = Sha256::digest(bytes);
1510 digest.iter().map(|byte| format!("{byte:02x}")).collect()
1511}
1512
1513#[cfg(feature = "pack-zip")]
1514fn write_zip_entries(path: &Path, entries: BTreeMap<String, Vec<u8>>) -> Result<(), String> {
1515 let file = fs::File::create(path)
1516 .map_err(|err| format!("failed to create gtpack {}: {err}", path.display()))?;
1517 let mut writer = ZipWriter::new(file);
1518 let timestamp = zip::DateTime::from_date_and_time(1980, 1, 1, 0, 0, 0)
1519 .map_err(|err| format!("failed to create stable zip timestamp: {err}"))?;
1520 let options = SimpleFileOptions::default()
1521 .compression_method(CompressionMethod::Stored)
1522 .last_modified_time(timestamp)
1523 .unix_permissions(0o644)
1524 .large_file(false);
1525 for (name, bytes) in entries {
1526 writer
1527 .start_file(&name, options)
1528 .map_err(|err| format!("failed to add {name} to gtpack: {err}"))?;
1529 writer
1530 .write_all(&bytes)
1531 .map_err(|err| format!("failed to write {name} to gtpack: {err}"))?;
1532 }
1533 writer
1534 .finish()
1535 .map_err(|err| format!("failed to finish gtpack archive: {err}"))?;
1536 Ok(())
1537}
1538
1539#[cfg(feature = "pack-zip")]
1540pub fn inspect_sorla_gtpack(path: &Path) -> Result<SorlaGtpackInspection, String> {
1541 let mut archive = open_gtpack(path)?;
1542 let names = zip_entry_names(&mut archive)?;
1543 let manifest_bytes = zip_bytes(&mut archive, "pack.cbor")?;
1544 let manifest: SorlaPackManifest = ciborium::de::from_reader(Cursor::new(manifest_bytes))
1545 .map_err(|err| format!("pack.cbor is invalid SoRLa pack manifest: {err}"))?;
1546 if manifest
1547 .extension
1548 .get("extension")
1549 .and_then(serde_json::Value::as_str)
1550 != Some(SORX_RUNTIME_EXTENSION_ID)
1551 {
1552 return Err(format!(
1553 "pack.cbor is missing `{SORX_RUNTIME_EXTENSION_ID}` extension"
1554 ));
1555 }
1556 let model_bytes = zip_bytes(&mut archive, "assets/sorla/model.cbor")?;
1557 let ir: CanonicalIr = ciborium::de::from_reader(Cursor::new(model_bytes))
1558 .map_err(|err| format!("assets/sorla/model.cbor is invalid canonical IR: {err}"))?;
1559 let validation = validation_manifest_summary(&mut archive, &manifest, &names)?;
1560 let exposure_policy = exposure_policy_summary(&mut archive, &manifest, &names)?;
1561 let compatibility = compatibility_summary(&mut archive, &manifest, &names)?;
1562 let ontology = ontology_summary(&mut archive, &manifest, &names)?;
1563 let retrieval_bindings = retrieval_summary(&mut archive, &manifest, &names)?;
1564 let designer_node_types = designer_node_types_summary(&mut archive, &manifest, &names)?;
1565 let agent_endpoint_action_catalog =
1566 agent_endpoint_action_catalog_summary(&mut archive, &manifest, &names)?;
1567 let optional_artifacts = [
1568 AGENT_ENDPOINTS_IR_CBOR_FILENAME,
1569 AGENT_OPENAPI_OVERLAY_FILENAME,
1570 AGENT_ARAZZO_FILENAME,
1571 MCP_TOOLS_FILENAME,
1572 LLMS_TXT_FRAGMENT_FILENAME,
1573 DESIGNER_NODE_TYPES_FILENAME,
1574 AGENT_ENDPOINT_ACTION_CATALOG_FILENAME,
1575 ONTOLOGY_GRAPH_FILENAME,
1576 ONTOLOGY_IR_CBOR_FILENAME,
1577 ONTOLOGY_SCHEMA_FILENAME,
1578 RETRIEVAL_BINDINGS_FILENAME,
1579 RETRIEVAL_BINDINGS_IR_CBOR_FILENAME,
1580 ]
1581 .into_iter()
1582 .map(|name| {
1583 (
1584 format!("assets/sorla/{name}"),
1585 names.contains(&format!("assets/sorla/{name}")),
1586 )
1587 })
1588 .collect();
1589
1590 Ok(SorlaGtpackInspection {
1591 path: path.display().to_string(),
1592 name: manifest.pack.name,
1593 version: manifest.pack.version,
1594 extension: manifest
1595 .extension
1596 .get("extension")
1597 .and_then(serde_json::Value::as_str)
1598 .unwrap_or(SORX_RUNTIME_EXTENSION_ID)
1599 .to_string(),
1600 sorla_package_name: ir.package.name.clone(),
1601 sorla_package_version: ir.package.version.clone(),
1602 ir_hash: canonical_hash_hex(&ir),
1603 assets: names
1604 .into_iter()
1605 .filter(|name| name.starts_with("assets/"))
1606 .collect(),
1607 optional_artifacts,
1608 validation,
1609 exposure_policy,
1610 compatibility,
1611 ontology,
1612 retrieval_bindings,
1613 designer_node_types,
1614 agent_endpoint_action_catalog,
1615 })
1616}
1617
1618fn ontology_extension_paths(
1619 manifest: &SorlaPackManifest,
1620) -> Result<Option<(String, String, String)>, String> {
1621 let Some(ontology) = manifest
1622 .extension
1623 .get("sorla")
1624 .and_then(|sorla| sorla.get("ontology"))
1625 else {
1626 return Ok(None);
1627 };
1628
1629 let schema = ontology
1630 .get("schema")
1631 .and_then(serde_json::Value::as_str)
1632 .ok_or_else(|| "pack.cbor ontology extension is missing `schema`".to_string())?;
1633 if schema != ONTOLOGY_EXTENSION_ID {
1634 return Err(format!(
1635 "pack.cbor ontology extension has unsupported schema `{schema}`"
1636 ));
1637 }
1638
1639 let graph = ontology_extension_path(ontology, "graph", ONTOLOGY_GRAPH_PATH)?;
1640 let ir = ontology_extension_path(ontology, "ir", ONTOLOGY_IR_CBOR_PATH)?;
1641 let schema_path = ontology_extension_path(ontology, "json_schema", ONTOLOGY_SCHEMA_PATH)?;
1642 Ok(Some((graph, ir, schema_path)))
1643}
1644
1645fn ontology_extension_path(
1646 ontology: &serde_json::Value,
1647 key: &str,
1648 expected: &str,
1649) -> Result<String, String> {
1650 let path = ontology
1651 .get(key)
1652 .and_then(serde_json::Value::as_str)
1653 .ok_or_else(|| format!("pack.cbor ontology extension is missing `{key}`"))?;
1654 if path != expected {
1655 return Err(format!(
1656 "pack.cbor references unsupported ontology asset path `{path}` for `{key}`"
1657 ));
1658 }
1659 validate_relative_pack_asset_path(path)?;
1660 Ok(path.to_string())
1661}
1662
1663fn validate_relative_pack_asset_path(path: &str) -> Result<(), String> {
1664 if path.starts_with('/') || path.contains("..") || !path.starts_with("assets/") {
1665 return Err(format!("pack.cbor references unsafe asset path `{path}`"));
1666 }
1667 Ok(())
1668}
1669
1670fn retrieval_extension_paths(
1671 manifest: &SorlaPackManifest,
1672) -> Result<Option<(String, String)>, String> {
1673 let Some(retrieval) = manifest
1674 .extension
1675 .get("sorla")
1676 .and_then(|sorla| sorla.get("retrieval_bindings"))
1677 else {
1678 return Ok(None);
1679 };
1680 let schema = retrieval
1681 .get("schema")
1682 .and_then(serde_json::Value::as_str)
1683 .ok_or_else(|| "pack.cbor retrieval_bindings extension is missing `schema`".to_string())?;
1684 if schema != RETRIEVAL_BINDINGS_SCHEMA {
1685 return Err(format!(
1686 "pack.cbor retrieval_bindings extension has unsupported schema `{schema}`"
1687 ));
1688 }
1689 let json = retrieval_extension_path(retrieval, "json", RETRIEVAL_BINDINGS_PATH)?;
1690 let ir = retrieval_extension_path(retrieval, "ir", RETRIEVAL_BINDINGS_IR_CBOR_PATH)?;
1691 Ok(Some((json, ir)))
1692}
1693
1694fn retrieval_extension_path(
1695 retrieval: &serde_json::Value,
1696 key: &str,
1697 expected: &str,
1698) -> Result<String, String> {
1699 let path = retrieval
1700 .get(key)
1701 .and_then(serde_json::Value::as_str)
1702 .ok_or_else(|| format!("pack.cbor retrieval_bindings extension is missing `{key}`"))?;
1703 if path != expected {
1704 return Err(format!(
1705 "pack.cbor references unsupported retrieval asset path `{path}` for `{key}`"
1706 ));
1707 }
1708 validate_relative_pack_asset_path(path)?;
1709 Ok(path.to_string())
1710}
1711
1712fn designer_node_types_extension_path(
1713 manifest: &SorlaPackManifest,
1714) -> Result<Option<String>, String> {
1715 let Some(node_types) = manifest
1716 .extension
1717 .get("sorla")
1718 .and_then(|sorla| sorla.get("designer_node_types"))
1719 else {
1720 return Ok(None);
1721 };
1722 let schema = node_types
1723 .get("schema")
1724 .and_then(serde_json::Value::as_str)
1725 .ok_or_else(|| "pack.cbor designer_node_types extension is missing `schema`".to_string())?;
1726 if schema != DESIGNER_NODE_TYPES_SCHEMA {
1727 return Err(format!(
1728 "pack.cbor designer_node_types extension has unsupported schema `{schema}`"
1729 ));
1730 }
1731 let json_path = node_types
1732 .get("json")
1733 .and_then(serde_json::Value::as_str)
1734 .ok_or_else(|| "pack.cbor designer_node_types extension is missing `json`".to_string())?;
1735 if json_path != DESIGNER_NODE_TYPES_PATH {
1736 return Err(format!(
1737 "pack.cbor references unsupported designer node types asset path `{json_path}`"
1738 ));
1739 }
1740 validate_relative_pack_asset_path(json_path)?;
1741 Ok(Some(json_path.to_string()))
1742}
1743
1744fn agent_endpoint_action_catalog_extension_path(
1745 manifest: &SorlaPackManifest,
1746) -> Result<Option<String>, String> {
1747 let Some(catalog) = manifest
1748 .extension
1749 .get("sorla")
1750 .and_then(|sorla| sorla.get("agent_endpoint_action_catalog"))
1751 else {
1752 return Ok(None);
1753 };
1754 let schema = catalog
1755 .get("schema")
1756 .and_then(serde_json::Value::as_str)
1757 .ok_or_else(|| {
1758 "pack.cbor agent_endpoint_action_catalog extension is missing `schema`".to_string()
1759 })?;
1760 if schema != AGENT_ENDPOINT_ACTION_CATALOG_SCHEMA {
1761 return Err(format!(
1762 "pack.cbor agent_endpoint_action_catalog extension has unsupported schema `{schema}`"
1763 ));
1764 }
1765 let json_path = catalog
1766 .get("json")
1767 .and_then(serde_json::Value::as_str)
1768 .ok_or_else(|| {
1769 "pack.cbor agent_endpoint_action_catalog extension is missing `json`".to_string()
1770 })?;
1771 if json_path != AGENT_ENDPOINT_ACTION_CATALOG_PATH {
1772 return Err(format!(
1773 "pack.cbor references unsupported agent endpoint action catalog asset path `{json_path}`"
1774 ));
1775 }
1776 validate_relative_pack_asset_path(json_path)?;
1777 Ok(Some(json_path.to_string()))
1778}
1779
1780#[cfg(feature = "pack-zip")]
1781fn sorx_extension_path(
1782 manifest: &SorlaPackManifest,
1783 key: &str,
1784 expected: &str,
1785) -> Result<Option<String>, String> {
1786 let Some(path) = manifest
1787 .extension
1788 .get("sorx")
1789 .and_then(|sorx| sorx.get(key))
1790 .and_then(serde_json::Value::as_str)
1791 else {
1792 return Ok(None);
1793 };
1794
1795 if path != expected {
1796 return Err(format!(
1797 "pack.cbor references unsupported SORX asset path `{path}` for `{key}`"
1798 ));
1799 }
1800 Ok(Some(path.to_string()))
1801}
1802
1803#[cfg(feature = "pack-zip")]
1804fn validation_manifest_path(manifest: &SorlaPackManifest) -> Result<Option<String>, String> {
1805 sorx_extension_path(
1806 manifest,
1807 "validation_manifest",
1808 SORX_VALIDATION_MANIFEST_PATH,
1809 )
1810}
1811
1812#[cfg(feature = "pack-zip")]
1813fn exposure_policy_path(manifest: &SorlaPackManifest) -> Result<Option<String>, String> {
1814 sorx_extension_path(manifest, "exposure_policy", SORX_EXPOSURE_POLICY_PATH)
1815}
1816
1817#[cfg(feature = "pack-zip")]
1818fn compatibility_path(manifest: &SorlaPackManifest) -> Result<Option<String>, String> {
1819 sorx_extension_path(manifest, "compatibility", SORX_COMPATIBILITY_PATH)
1820}
1821
1822#[cfg(feature = "pack-zip")]
1823fn ontology_summary<R: Read + Seek>(
1824 archive: &mut ZipArchive<R>,
1825 manifest: &SorlaPackManifest,
1826 names: &BTreeSet<String>,
1827) -> Result<Option<SorlaGtpackOntologyInspection>, String> {
1828 let Some((graph_path, ir_path, schema_path)) = ontology_extension_paths(manifest)? else {
1829 return Ok(None);
1830 };
1831 for path in [&graph_path, &ir_path, &schema_path] {
1832 if !names.contains(path) {
1833 return Err(format!(
1834 "pack.cbor references missing ontology asset `{path}`"
1835 ));
1836 }
1837 }
1838
1839 let ontology_ir = read_ontology_ir(archive, &ir_path)?;
1840 let graph = read_ontology_graph(archive, &graph_path)?;
1841 let ir_bytes = zip_bytes(archive, &ir_path)?;
1842 let ir_hash = sha256_hex(&ir_bytes);
1843 if graph["ir_hash"].as_str() != Some(ir_hash.as_str()) {
1844 return Err("ontology.graph.json ir_hash does not match ontology.ir.cbor".to_string());
1845 }
1846
1847 Ok(Some(SorlaGtpackOntologyInspection {
1848 schema: ontology_ir.schema.clone(),
1849 graph_schema: graph["schema"].as_str().unwrap_or("").to_string(),
1850 concept_count: ontology_ir.concepts.len(),
1851 relationship_count: ontology_ir.relationships.len(),
1852 constraint_count: ontology_ir.constraints.len(),
1853 ir_hash,
1854 }))
1855}
1856
1857#[cfg(feature = "pack-zip")]
1858fn retrieval_summary<R: Read + Seek>(
1859 archive: &mut ZipArchive<R>,
1860 manifest: &SorlaPackManifest,
1861 names: &BTreeSet<String>,
1862) -> Result<Option<SorlaGtpackRetrievalInspection>, String> {
1863 let Some((json_path, ir_path)) = retrieval_extension_paths(manifest)? else {
1864 return Ok(None);
1865 };
1866 for path in [&json_path, &ir_path] {
1867 if !names.contains(path) {
1868 return Err(format!(
1869 "pack.cbor references missing retrieval asset `{path}`"
1870 ));
1871 }
1872 }
1873 let json = read_retrieval_json(archive, &json_path)?;
1874 let ir = read_retrieval_ir(archive, &ir_path)?;
1875 if json != ir {
1876 return Err(
1877 "retrieval-bindings.json does not match retrieval-bindings.ir.cbor".to_string(),
1878 );
1879 }
1880 Ok(Some(SorlaGtpackRetrievalInspection {
1881 schema: ir.schema,
1882 provider_count: ir.providers.len(),
1883 scope_count: ir.scopes.len(),
1884 }))
1885}
1886
1887#[cfg(feature = "pack-zip")]
1888fn designer_node_types_summary<R: Read + Seek>(
1889 archive: &mut ZipArchive<R>,
1890 manifest: &SorlaPackManifest,
1891 names: &BTreeSet<String>,
1892) -> Result<Option<SorlaGtpackDesignerNodeTypesInspection>, String> {
1893 let Some(json_path) = designer_node_types_extension_path(manifest)? else {
1894 return Ok(None);
1895 };
1896 if !names.contains(&json_path) {
1897 return Err(format!(
1898 "pack.cbor references missing designer node types asset `{json_path}`"
1899 ));
1900 }
1901 let document = read_designer_node_types(archive, &json_path)?;
1902 Ok(Some(SorlaGtpackDesignerNodeTypesInspection {
1903 schema: document.schema,
1904 count: document.node_types.len(),
1905 }))
1906}
1907
1908#[cfg(feature = "pack-zip")]
1909fn agent_endpoint_action_catalog_summary<R: Read + Seek>(
1910 archive: &mut ZipArchive<R>,
1911 manifest: &SorlaPackManifest,
1912 names: &BTreeSet<String>,
1913) -> Result<Option<SorlaGtpackAgentEndpointActionCatalogInspection>, String> {
1914 let Some(json_path) = agent_endpoint_action_catalog_extension_path(manifest)? else {
1915 return Ok(None);
1916 };
1917 if !names.contains(&json_path) {
1918 return Err(format!(
1919 "pack.cbor references missing agent endpoint action catalog asset `{json_path}`"
1920 ));
1921 }
1922 let document = read_agent_endpoint_action_catalog(archive, &json_path)?;
1923 Ok(Some(SorlaGtpackAgentEndpointActionCatalogInspection {
1924 schema: document.schema,
1925 count: document.actions.len(),
1926 }))
1927}
1928
1929#[cfg(feature = "pack-zip")]
1930fn compatibility_summary<R: Read + Seek>(
1931 archive: &mut ZipArchive<R>,
1932 manifest: &SorlaPackManifest,
1933 names: &BTreeSet<String>,
1934) -> Result<Option<SorlaGtpackCompatibilityInspection>, String> {
1935 let Some(path) = compatibility_path(manifest)? else {
1936 return Ok(None);
1937 };
1938 if !names.contains(&path) {
1939 return Err(format!(
1940 "pack.cbor references missing compatibility manifest `{path}`"
1941 ));
1942 }
1943
1944 let compatibility = read_compatibility_manifest(archive, &path)?;
1945 compatibility
1946 .validate_static()
1947 .map_err(|err| err.to_string())?;
1948 Ok(Some(SorlaGtpackCompatibilityInspection {
1949 api_mode: compatibility.api_compatibility,
1950 state_mode: compatibility.state_compatibility,
1951 provider_requirement_count: compatibility.provider_compatibility.len(),
1952 migration_rule_count: compatibility.migration_compatibility.len(),
1953 }))
1954}
1955
1956#[cfg(feature = "pack-zip")]
1957fn exposure_policy_summary<R: Read + Seek>(
1958 archive: &mut ZipArchive<R>,
1959 manifest: &SorlaPackManifest,
1960 names: &BTreeSet<String>,
1961) -> Result<Option<SorlaGtpackExposurePolicyInspection>, String> {
1962 let Some(path) = exposure_policy_path(manifest)? else {
1963 return Ok(None);
1964 };
1965 if !names.contains(&path) {
1966 return Err(format!(
1967 "pack.cbor references missing exposure policy `{path}`"
1968 ));
1969 }
1970
1971 let policy = read_exposure_policy(archive, &path)?;
1972 Ok(Some(SorlaGtpackExposurePolicyInspection {
1973 default_visibility: policy.default_visibility,
1974 public_candidate_endpoints: policy
1975 .endpoints
1976 .iter()
1977 .filter(|endpoint| endpoint.visibility == EndpointVisibility::PublicCandidate)
1978 .count(),
1979 approval_required_endpoints: policy
1980 .endpoints
1981 .iter()
1982 .filter(|endpoint| endpoint.requires_approval)
1983 .count(),
1984 }))
1985}
1986
1987#[cfg(feature = "pack-zip")]
1988fn validation_manifest_summary<R: Read + Seek>(
1989 archive: &mut ZipArchive<R>,
1990 manifest: &SorlaPackManifest,
1991 names: &BTreeSet<String>,
1992) -> Result<Option<SorlaGtpackValidationInspection>, String> {
1993 let Some(path) = validation_manifest_path(manifest)? else {
1994 return Ok(None);
1995 };
1996 if !names.contains(&path) {
1997 return Err(format!(
1998 "pack.cbor references missing validation manifest `{path}`"
1999 ));
2000 }
2001
2002 let validation = read_validation_manifest(archive, &path)?;
2003 validation
2004 .validate_static()
2005 .map_err(|err| err.to_string())?;
2006 Ok(Some(SorlaGtpackValidationInspection {
2007 schema: validation.schema,
2008 suite_count: validation.suites.len(),
2009 test_count: validation
2010 .suites
2011 .iter()
2012 .map(|suite| suite.tests.len())
2013 .sum(),
2014 promotion_requires: validation.promotion_requires,
2015 }))
2016}
2017
2018#[cfg(feature = "pack-zip")]
2019fn validate_embedded_sorx_validation<R: Read + Seek>(
2020 archive: &mut ZipArchive<R>,
2021 names: &BTreeSet<String>,
2022 ir: &CanonicalIr,
2023) -> Result<(), String> {
2024 let manifest_bytes = zip_bytes(archive, "pack.cbor")?;
2025 let pack_manifest: SorlaPackManifest =
2026 ciborium::de::from_reader(Cursor::new(manifest_bytes))
2027 .map_err(|err| format!("pack.cbor is invalid SoRLa pack manifest: {err}"))?;
2028 let path = validation_manifest_path(&pack_manifest)?
2029 .ok_or_else(|| "pack.cbor is missing `sorx.validation_manifest`".to_string())?;
2030 if !names.contains(&path) {
2031 return Err(format!(
2032 "pack.cbor references missing validation manifest `{path}`"
2033 ));
2034 }
2035 if !pack_manifest.assets.iter().any(|asset| asset == &path) {
2036 return Err(format!("pack.cbor assets do not include `{path}`"));
2037 }
2038 validate_lock_includes_entry(archive, &path)?;
2039
2040 let validation = read_validation_manifest(archive, &path)?;
2041 validation
2042 .validate_static()
2043 .map_err(|err| err.to_string())?;
2044 if validation.package.name != ir.package.name {
2045 return Err(format!(
2046 "validation manifest package.name `{}` does not match SoRLa package `{}`",
2047 validation.package.name, ir.package.name
2048 ));
2049 }
2050 if validation.package.version != ir.package.version {
2051 return Err(format!(
2052 "validation manifest package.version `{}` does not match SoRLa package `{}`",
2053 validation.package.version, ir.package.version
2054 ));
2055 }
2056 for suite in &validation.suites {
2057 for test in &suite.tests {
2058 for reference in test.referenced_asset_paths() {
2059 let asset_path = format!("assets/sorx/tests/{reference}");
2060 if !names.contains(&asset_path) {
2061 return Err(format!(
2062 "validation manifest references missing asset `{asset_path}`"
2063 ));
2064 }
2065 }
2066 }
2067 }
2068
2069 Ok(())
2070}
2071
2072#[cfg(feature = "pack-zip")]
2073fn validate_embedded_sorx_exposure_policy<R: Read + Seek>(
2074 archive: &mut ZipArchive<R>,
2075 names: &BTreeSet<String>,
2076 ir: &CanonicalIr,
2077) -> Result<(), String> {
2078 let manifest_bytes = zip_bytes(archive, "pack.cbor")?;
2079 let pack_manifest: SorlaPackManifest =
2080 ciborium::de::from_reader(Cursor::new(manifest_bytes))
2081 .map_err(|err| format!("pack.cbor is invalid SoRLa pack manifest: {err}"))?;
2082 let path = exposure_policy_path(&pack_manifest)?
2083 .ok_or_else(|| "pack.cbor is missing `sorx.exposure_policy`".to_string())?;
2084 if !names.contains(&path) {
2085 return Err(format!(
2086 "pack.cbor references missing exposure policy `{path}`"
2087 ));
2088 }
2089 if !pack_manifest.assets.iter().any(|asset| asset == &path) {
2090 return Err(format!("pack.cbor assets do not include `{path}`"));
2091 }
2092 validate_lock_includes_entry(archive, &path)?;
2093
2094 let known_endpoint_ids = ir
2095 .agent_endpoints
2096 .iter()
2097 .map(|endpoint| endpoint.id.as_str())
2098 .collect();
2099 let policy = read_exposure_policy(archive, &path)?;
2100 policy
2101 .validate_static(&known_endpoint_ids)
2102 .map_err(|err| err.to_string())?;
2103 validate_exposure_policy_against_validation(archive, &policy)?;
2104 Ok(())
2105}
2106
2107#[cfg(feature = "pack-zip")]
2108fn validate_embedded_sorx_compatibility<R: Read + Seek>(
2109 archive: &mut ZipArchive<R>,
2110 names: &BTreeSet<String>,
2111 ir: &CanonicalIr,
2112) -> Result<(), String> {
2113 let manifest_bytes = zip_bytes(archive, "pack.cbor")?;
2114 let pack_manifest: SorlaPackManifest =
2115 ciborium::de::from_reader(Cursor::new(manifest_bytes))
2116 .map_err(|err| format!("pack.cbor is invalid SoRLa pack manifest: {err}"))?;
2117 let path = compatibility_path(&pack_manifest)?
2118 .ok_or_else(|| "pack.cbor is missing `sorx.compatibility`".to_string())?;
2119 if !names.contains(&path) {
2120 return Err(format!(
2121 "pack.cbor references missing compatibility manifest `{path}`"
2122 ));
2123 }
2124 if !pack_manifest.assets.iter().any(|asset| asset == &path) {
2125 return Err(format!("pack.cbor assets do not include `{path}`"));
2126 }
2127 validate_lock_includes_entry(archive, &path)?;
2128
2129 let compatibility = read_compatibility_manifest(archive, &path)?;
2130 compatibility
2131 .validate_static()
2132 .map_err(|err| err.to_string())?;
2133 if compatibility.package.name != ir.package.name {
2134 return Err(format!(
2135 "compatibility manifest package.name `{}` does not match SoRLa package `{}`",
2136 compatibility.package.name, ir.package.name
2137 ));
2138 }
2139 if compatibility.package.version != ir.package.version {
2140 return Err(format!(
2141 "compatibility manifest package.version `{}` does not match SoRLa package `{}`",
2142 compatibility.package.version, ir.package.version
2143 ));
2144 }
2145 Ok(())
2146}
2147
2148#[cfg(feature = "pack-zip")]
2149fn validate_embedded_ontology_artifacts<R: Read + Seek>(
2150 archive: &mut ZipArchive<R>,
2151 names: &BTreeSet<String>,
2152 ir: &CanonicalIr,
2153) -> Result<(), String> {
2154 let manifest_bytes = zip_bytes(archive, "pack.cbor")?;
2155 let pack_manifest: SorlaPackManifest =
2156 ciborium::de::from_reader(Cursor::new(manifest_bytes))
2157 .map_err(|err| format!("pack.cbor is invalid SoRLa pack manifest: {err}"))?;
2158 let declared_paths = ontology_extension_paths(&pack_manifest)?;
2159 let Some(expected_ontology) = &ir.ontology else {
2160 if declared_paths.is_some() {
2161 return Err(
2162 "pack.cbor declares ontology extension but model.cbor has no ontology".into(),
2163 );
2164 }
2165 return Ok(());
2166 };
2167 let Some((graph_path, ir_path, schema_path)) = declared_paths else {
2168 return Err("pack.cbor is missing sorla ontology extension".to_string());
2169 };
2170
2171 for path in [&graph_path, &ir_path, &schema_path] {
2172 if !names.contains(path) {
2173 return Err(format!(
2174 "pack.cbor references missing ontology asset `{path}`"
2175 ));
2176 }
2177 if !pack_manifest.assets.iter().any(|asset| asset == path) {
2178 return Err(format!("pack.cbor assets do not include `{path}`"));
2179 }
2180 validate_lock_includes_entry(archive, path)?;
2181 }
2182
2183 let emitted_ontology = read_ontology_ir(archive, &ir_path)?;
2184 if &emitted_ontology != expected_ontology {
2185 return Err("ontology.ir.cbor does not match model.cbor ontology IR".to_string());
2186 }
2187
2188 let graph = read_ontology_graph(archive, &graph_path)?;
2189 let ir_bytes = zip_bytes(archive, &ir_path)?;
2190 let ir_hash = sha256_hex(&ir_bytes);
2191 if graph["ir_hash"].as_str() != Some(ir_hash.as_str()) {
2192 return Err("ontology.graph.json ir_hash does not match ontology.ir.cbor".to_string());
2193 }
2194 if graph["package"]["name"].as_str() != Some(ir.package.name.as_str()) {
2195 return Err("ontology.graph.json package.name does not match model.cbor".to_string());
2196 }
2197 if graph["package"]["version"].as_str() != Some(ir.package.version.as_str()) {
2198 return Err("ontology.graph.json package.version does not match model.cbor".to_string());
2199 }
2200 let graph_concepts = graph_array::<greentic_sorla_ir::ConceptDefinitionIr>(&graph, "concepts")?;
2201 if graph_concepts != emitted_ontology.concepts {
2202 return Err("ontology.graph.json concepts do not match ontology.ir.cbor".to_string());
2203 }
2204 let graph_relationships =
2205 graph_array::<greentic_sorla_ir::RelationshipDefinitionIr>(&graph, "relationships")?;
2206 if graph_relationships != emitted_ontology.relationships {
2207 return Err("ontology.graph.json relationships do not match ontology.ir.cbor".to_string());
2208 }
2209 let graph_constraints =
2210 graph_array::<greentic_sorla_ir::OntologyConstraintIr>(&graph, "constraints")?;
2211 if graph_constraints != emitted_ontology.constraints {
2212 return Err("ontology.graph.json constraints do not match ontology.ir.cbor".to_string());
2213 }
2214 let graph_aliases: SemanticAliasesIr =
2215 serde_json::from_value(graph["semantic_aliases"].clone())
2216 .map_err(|err| format!("ontology.graph.json `semantic_aliases` is invalid: {err}"))?;
2217 if graph_aliases != emitted_ontology.semantic_aliases {
2218 return Err(
2219 "ontology.graph.json semantic_aliases do not match ontology.ir.cbor".to_string(),
2220 );
2221 }
2222 let graph_linking: EntityLinkingIr = serde_json::from_value(graph["entity_linking"].clone())
2223 .map_err(|err| format!("ontology.graph.json `entity_linking` is invalid: {err}"))?;
2224 if graph_linking != emitted_ontology.entity_linking {
2225 return Err(
2226 "ontology.graph.json entity_linking does not match ontology.ir.cbor".to_string(),
2227 );
2228 }
2229
2230 validate_ontology_backing(&emitted_ontology, ir)?;
2231 let schema_json: serde_json::Value = serde_json::from_str(&zip_text(archive, &schema_path)?)
2232 .map_err(|err| format!("{schema_path} is invalid JSON: {err}"))?;
2233 if schema_json["$id"].as_str() != Some(ONTOLOGY_EXTENSION_ID) {
2234 return Err("ontology.schema.json has unsupported $id".to_string());
2235 }
2236 Ok(())
2237}
2238
2239#[cfg(feature = "pack-zip")]
2240fn validate_embedded_retrieval_bindings<R: Read + Seek>(
2241 archive: &mut ZipArchive<R>,
2242 names: &BTreeSet<String>,
2243 ir: &CanonicalIr,
2244) -> Result<(), String> {
2245 let manifest_bytes = zip_bytes(archive, "pack.cbor")?;
2246 let pack_manifest: SorlaPackManifest =
2247 ciborium::de::from_reader(Cursor::new(manifest_bytes))
2248 .map_err(|err| format!("pack.cbor is invalid SoRLa pack manifest: {err}"))?;
2249 let declared_paths = retrieval_extension_paths(&pack_manifest)?;
2250 let Some(expected) = &ir.retrieval_bindings else {
2251 if declared_paths.is_some() {
2252 return Err(
2253 "pack.cbor declares retrieval_bindings extension but model.cbor has no retrieval bindings"
2254 .to_string(),
2255 );
2256 }
2257 return Ok(());
2258 };
2259 let Some((json_path, ir_path)) = declared_paths else {
2260 return Err("pack.cbor is missing sorla retrieval_bindings extension".to_string());
2261 };
2262 for path in [&json_path, &ir_path] {
2263 if !names.contains(path) {
2264 return Err(format!(
2265 "pack.cbor references missing retrieval asset `{path}`"
2266 ));
2267 }
2268 if !pack_manifest.assets.iter().any(|asset| asset == path) {
2269 return Err(format!("pack.cbor assets do not include `{path}`"));
2270 }
2271 validate_lock_includes_entry(archive, path)?;
2272 }
2273 let json = read_retrieval_json(archive, &json_path)?;
2274 let cbor = read_retrieval_ir(archive, &ir_path)?;
2275 if &json != expected || &cbor != expected {
2276 return Err("retrieval bindings assets do not match model.cbor".to_string());
2277 }
2278 Ok(())
2279}
2280
2281#[cfg(feature = "pack-zip")]
2282fn validate_embedded_designer_node_types<R: Read + Seek>(
2283 archive: &mut ZipArchive<R>,
2284 names: &BTreeSet<String>,
2285 ir: &CanonicalIr,
2286) -> Result<(), String> {
2287 let manifest_bytes = zip_bytes(archive, "pack.cbor")?;
2288 let pack_manifest: SorlaPackManifest =
2289 ciborium::de::from_reader(Cursor::new(manifest_bytes))
2290 .map_err(|err| format!("pack.cbor is invalid SoRLa pack manifest: {err}"))?;
2291 let declared_path = designer_node_types_extension_path(&pack_manifest)?;
2292 if ir.agent_endpoints.is_empty() {
2293 if declared_path.is_some() {
2294 return Err(
2295 "pack.cbor declares designer_node_types extension but model.cbor has no agent endpoints"
2296 .to_string(),
2297 );
2298 }
2299 return Ok(());
2300 }
2301 let Some(json_path) = declared_path else {
2302 return Err("pack.cbor is missing sorla designer_node_types extension".to_string());
2303 };
2304 if !names.contains(&json_path) {
2305 return Err(format!(
2306 "pack.cbor references missing designer node types asset `{json_path}`"
2307 ));
2308 }
2309 if !pack_manifest.assets.iter().any(|asset| asset == &json_path) {
2310 return Err(format!("pack.cbor assets do not include `{json_path}`"));
2311 }
2312 validate_lock_includes_entry(archive, &json_path)?;
2313
2314 let document = read_designer_node_types(archive, &json_path)?;
2315 let expected_hash = canonical_hash_hex(ir);
2316 let expected_contract_hash = format!("sha256:{expected_hash}");
2317 if document.package.name != ir.package.name
2318 || document.package.version != ir.package.version
2319 || document.package.ir_hash != expected_hash
2320 {
2321 return Err(
2322 "designer-node-types.json package metadata does not match model.cbor".to_string(),
2323 );
2324 }
2325 if document.node_types.len() != ir.agent_endpoints.len() {
2326 return Err(format!(
2327 "designer-node-types.json has {} node types but model.cbor has {} agent endpoints",
2328 document.node_types.len(),
2329 ir.agent_endpoints.len()
2330 ));
2331 }
2332
2333 let endpoints = ir
2334 .agent_endpoints
2335 .iter()
2336 .map(|endpoint| (endpoint.id.as_str(), endpoint))
2337 .collect::<BTreeMap<_, _>>();
2338 let mut seen = BTreeSet::new();
2339 for node_type in &document.node_types {
2340 let endpoint_id = node_type.metadata.endpoint.id.as_str();
2341 let Some(endpoint) = endpoints.get(endpoint_id) else {
2342 return Err(format!(
2343 "designer-node-types.json references unknown endpoint `{endpoint_id}`"
2344 ));
2345 };
2346 if !seen.insert(endpoint_id) {
2347 return Err(format!(
2348 "designer-node-types.json contains duplicate endpoint `{endpoint_id}`"
2349 ));
2350 }
2351 if node_type.id != format!("sorla.agent-endpoint.{endpoint_id}") {
2352 return Err(format!(
2353 "designer-node-types.json node `{}` does not match endpoint `{endpoint_id}`",
2354 node_type.id
2355 ));
2356 }
2357 if node_type.binding.kind != "component" {
2358 return Err(format!(
2359 "designer-node-types.json node `{}` has unsupported binding kind `{}`",
2360 node_type.id, node_type.binding.kind
2361 ));
2362 }
2363 if node_type.binding.operation != DEFAULT_DESIGNER_COMPONENT_OPERATION {
2364 return Err(format!(
2365 "designer-node-types.json node `{}` has unsupported operation `{}`",
2366 node_type.id, node_type.binding.operation
2367 ));
2368 }
2369 if !is_sha256_contract_hash(&node_type.metadata.endpoint.contract_hash) {
2370 return Err(format!(
2371 "designer-node-types.json node `{}` has invalid contract_hash `{}`",
2372 node_type.id, node_type.metadata.endpoint.contract_hash
2373 ));
2374 }
2375 if node_type.metadata.endpoint.version != ir.package.version
2376 || node_type.metadata.endpoint.package != ir.package.name
2377 || node_type.metadata.endpoint.contract_hash != expected_contract_hash
2378 {
2379 return Err(format!(
2380 "designer-node-types.json node `{}` endpoint_ref does not match model metadata",
2381 node_type.id
2382 ));
2383 }
2384 let config_ref = node_type
2385 .config_schema
2386 .get("properties")
2387 .and_then(|properties| properties.get("endpoint_ref"))
2388 .and_then(|endpoint_ref| endpoint_ref.get("const"))
2389 .ok_or_else(|| {
2390 format!(
2391 "designer-node-types.json node `{}` is missing locked endpoint_ref config",
2392 node_type.id
2393 )
2394 })?;
2395 if config_ref.get("id").and_then(serde_json::Value::as_str) != Some(endpoint_id) {
2396 return Err(format!(
2397 "designer-node-types.json node `{}` config endpoint_ref does not match metadata",
2398 node_type.id
2399 ));
2400 }
2401 if config_ref
2402 .get("package")
2403 .and_then(serde_json::Value::as_str)
2404 != Some(ir.package.name.as_str())
2405 || config_ref
2406 .get("version")
2407 .and_then(serde_json::Value::as_str)
2408 != Some(ir.package.version.as_str())
2409 || config_ref
2410 .get("contract_hash")
2411 .and_then(serde_json::Value::as_str)
2412 != Some(expected_contract_hash.as_str())
2413 {
2414 return Err(format!(
2415 "designer-node-types.json node `{}` config endpoint_ref does not match model metadata",
2416 node_type.id
2417 ));
2418 }
2419 reject_free_text_runtime_selection(&serde_json::to_value(node_type).unwrap_or_default())?;
2420 for input in endpoint.inputs.iter().filter(|input| input.required) {
2421 let has_required = node_type
2422 .input_schema
2423 .get("required")
2424 .and_then(serde_json::Value::as_array)
2425 .is_some_and(|required| {
2426 required
2427 .iter()
2428 .any(|item| item.as_str() == Some(input.name.as_str()))
2429 });
2430 if !has_required {
2431 return Err(format!(
2432 "designer-node-types.json node `{}` input schema is missing required input `{}`",
2433 node_type.id, input.name
2434 ));
2435 }
2436 }
2437 }
2438 Ok(())
2439}
2440
2441#[cfg(feature = "pack-zip")]
2442fn validate_embedded_agent_endpoint_action_catalog<R: Read + Seek>(
2443 archive: &mut ZipArchive<R>,
2444 names: &BTreeSet<String>,
2445 ir: &CanonicalIr,
2446) -> Result<(), String> {
2447 let manifest_bytes = zip_bytes(archive, "pack.cbor")?;
2448 let pack_manifest: SorlaPackManifest =
2449 ciborium::de::from_reader(Cursor::new(manifest_bytes))
2450 .map_err(|err| format!("pack.cbor is invalid SoRLa pack manifest: {err}"))?;
2451 let declared_path = agent_endpoint_action_catalog_extension_path(&pack_manifest)?;
2452 if ir.agent_endpoints.is_empty() {
2453 if declared_path.is_some() {
2454 return Err(
2455 "pack.cbor declares agent_endpoint_action_catalog extension but model.cbor has no agent endpoints"
2456 .to_string(),
2457 );
2458 }
2459 return Ok(());
2460 }
2461 let Some(json_path) = declared_path else {
2462 return Err(
2463 "pack.cbor is missing sorla agent_endpoint_action_catalog extension".to_string(),
2464 );
2465 };
2466 if !names.contains(&json_path) {
2467 return Err(format!(
2468 "pack.cbor references missing agent endpoint action catalog asset `{json_path}`"
2469 ));
2470 }
2471 if !pack_manifest.assets.iter().any(|asset| asset == &json_path) {
2472 return Err(format!("pack.cbor assets do not include `{json_path}`"));
2473 }
2474 validate_lock_includes_entry(archive, &json_path)?;
2475
2476 let document = read_agent_endpoint_action_catalog(archive, &json_path)?;
2477 let expected_hash = canonical_hash_hex(ir);
2478 let expected_contract_hash = format!("sha256:{expected_hash}");
2479 if document.package.name != ir.package.name
2480 || document.package.version != ir.package.version
2481 || document.package.ir_hash != expected_hash
2482 {
2483 return Err(
2484 "agent-endpoint-action-catalog.json package metadata does not match model.cbor"
2485 .to_string(),
2486 );
2487 }
2488 if document.actions.len() != ir.agent_endpoints.len() {
2489 return Err(format!(
2490 "agent-endpoint-action-catalog.json has {} actions but model.cbor has {} agent endpoints",
2491 document.actions.len(),
2492 ir.agent_endpoints.len()
2493 ));
2494 }
2495
2496 let endpoints = ir
2497 .agent_endpoints
2498 .iter()
2499 .map(|endpoint| (endpoint.id.as_str(), endpoint))
2500 .collect::<BTreeMap<_, _>>();
2501 let mut seen = BTreeSet::new();
2502 for action in &document.actions {
2503 let Some(endpoint) = endpoints.get(action.id.as_str()) else {
2504 return Err(format!(
2505 "agent-endpoint-action-catalog.json references unknown endpoint `{}`",
2506 action.id
2507 ));
2508 };
2509 if !seen.insert(action.id.as_str()) {
2510 return Err(format!(
2511 "agent-endpoint-action-catalog.json contains duplicate endpoint `{}`",
2512 action.id
2513 ));
2514 }
2515 if action.endpoint_ref.id != action.id
2516 || action.endpoint_ref.version != ir.package.version
2517 || action.endpoint_ref.package != ir.package.name
2518 || action.endpoint_ref.contract_hash != expected_contract_hash
2519 {
2520 return Err(format!(
2521 "agent-endpoint-action-catalog.json action `{}` endpoint_ref does not match model metadata",
2522 action.id
2523 ));
2524 }
2525 if !is_sha256_contract_hash(&action.endpoint_ref.contract_hash) {
2526 return Err(format!(
2527 "agent-endpoint-action-catalog.json action `{}` has invalid contract_hash `{}`",
2528 action.id, action.endpoint_ref.contract_hash
2529 ));
2530 }
2531 for input in endpoint.inputs.iter().filter(|input| input.required) {
2532 let has_required = action
2533 .input_schema
2534 .get("required")
2535 .and_then(serde_json::Value::as_array)
2536 .is_some_and(|required| {
2537 required
2538 .iter()
2539 .any(|item| item.as_str() == Some(input.name.as_str()))
2540 });
2541 if !has_required {
2542 return Err(format!(
2543 "agent-endpoint-action-catalog.json action `{}` input schema is missing required input `{}`",
2544 action.id, input.name
2545 ));
2546 }
2547 }
2548 reject_free_text_runtime_selection(&serde_json::to_value(action).unwrap_or_default())?;
2549 }
2550 Ok(())
2551}
2552
2553fn is_sha256_contract_hash(value: &str) -> bool {
2554 let Some(hex) = value.strip_prefix("sha256:") else {
2555 return false;
2556 };
2557 hex.len() == 64
2558 && hex
2559 .bytes()
2560 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
2561}
2562
2563fn reject_free_text_runtime_selection(value: &serde_json::Value) -> Result<(), String> {
2564 const FORBIDDEN_KEYS: [&str; 4] = [
2565 "action_label",
2566 "action_alias",
2567 "intent_query",
2568 "natural_language_action",
2569 ];
2570 match value {
2571 serde_json::Value::Object(object) => {
2572 for (key, nested) in object {
2573 if FORBIDDEN_KEYS.contains(&key.as_str()) {
2574 return Err(format!(
2575 "generated metadata contains forbidden runtime action selection field `{key}`"
2576 ));
2577 }
2578 reject_free_text_runtime_selection(nested)?;
2579 }
2580 }
2581 serde_json::Value::Array(items) => {
2582 for item in items {
2583 reject_free_text_runtime_selection(item)?;
2584 }
2585 }
2586 _ => {}
2587 }
2588 Ok(())
2589}
2590
2591#[cfg(feature = "pack-zip")]
2592fn graph_array<T>(graph: &serde_json::Value, key: &str) -> Result<Vec<T>, String>
2593where
2594 T: serde::de::DeserializeOwned,
2595{
2596 serde_json::from_value(graph[key].clone())
2597 .map_err(|err| format!("ontology.graph.json `{key}` is invalid: {err}"))
2598}
2599
2600#[cfg(feature = "pack-zip")]
2601fn validate_ontology_backing(ontology: &OntologyModelIr, ir: &CanonicalIr) -> Result<(), String> {
2602 let records: BTreeMap<_, _> = ir
2603 .records
2604 .iter()
2605 .map(|record| (record.name.as_str(), record))
2606 .collect();
2607 for concept in &ontology.concepts {
2608 if let Some(backing) = &concept.backing {
2609 validate_ontology_backing_ref(&records, backing, &format!("concept `{}`", concept.id))?;
2610 }
2611 }
2612 for relationship in &ontology.relationships {
2613 if let Some(backing) = &relationship.backing {
2614 validate_ontology_backing_ref(
2615 &records,
2616 backing,
2617 &format!("relationship `{}`", relationship.id),
2618 )?;
2619 }
2620 }
2621 Ok(())
2622}
2623
2624#[cfg(feature = "pack-zip")]
2625fn validate_ontology_backing_ref(
2626 records: &BTreeMap<&str, &greentic_sorla_ir::RecordIr>,
2627 backing: &greentic_sorla_ir::OntologyBackingIr,
2628 label: &str,
2629) -> Result<(), String> {
2630 let record = records.get(backing.record.as_str()).ok_or_else(|| {
2631 format!(
2632 "ontology {label} backing references unknown record `{}`",
2633 backing.record
2634 )
2635 })?;
2636 for field in [&backing.from_field, &backing.to_field]
2637 .into_iter()
2638 .flatten()
2639 {
2640 if !record
2641 .fields
2642 .iter()
2643 .any(|candidate| candidate.name == *field)
2644 {
2645 return Err(format!(
2646 "ontology {label} backing references unknown field `{}` on record `{}`",
2647 field, backing.record
2648 ));
2649 }
2650 }
2651 Ok(())
2652}
2653
2654#[cfg(feature = "pack-zip")]
2655fn validate_exposure_policy_against_validation<R: Read + Seek>(
2656 archive: &mut ZipArchive<R>,
2657 policy: &SorxExposurePolicy,
2658) -> Result<(), String> {
2659 let manifest_bytes = zip_bytes(archive, "pack.cbor")?;
2660 let pack_manifest: SorlaPackManifest =
2661 ciborium::de::from_reader(Cursor::new(manifest_bytes))
2662 .map_err(|err| format!("pack.cbor is invalid SoRLa pack manifest: {err}"))?;
2663 let validation_path = validation_manifest_path(&pack_manifest)?
2664 .ok_or_else(|| "pack.cbor is missing `sorx.validation_manifest`".to_string())?;
2665 let validation = read_validation_manifest(archive, &validation_path)?;
2666 if policy
2667 .promotion_requires
2668 .iter()
2669 .any(|requirement| requirement == "validation_success")
2670 && validation.promotion_requires.is_empty()
2671 {
2672 return Err(
2673 "exposure policy requires validation_success but validation has no promotion suites"
2674 .to_string(),
2675 );
2676 }
2677 Ok(())
2678}
2679
2680#[cfg(feature = "pack-zip")]
2681fn read_validation_manifest<R: Read + Seek>(
2682 archive: &mut ZipArchive<R>,
2683 path: &str,
2684) -> Result<SorxValidationManifest, String> {
2685 serde_json::from_str(&zip_text(archive, path)?)
2686 .map_err(|err| format!("{path} is invalid JSON: {err}"))
2687}
2688
2689#[cfg(feature = "pack-zip")]
2690fn read_exposure_policy<R: Read + Seek>(
2691 archive: &mut ZipArchive<R>,
2692 path: &str,
2693) -> Result<SorxExposurePolicy, String> {
2694 serde_json::from_str(&zip_text(archive, path)?)
2695 .map_err(|err| format!("{path} is invalid JSON: {err}"))
2696}
2697
2698#[cfg(feature = "pack-zip")]
2699fn read_compatibility_manifest<R: Read + Seek>(
2700 archive: &mut ZipArchive<R>,
2701 path: &str,
2702) -> Result<SorxCompatibilityManifest, String> {
2703 serde_json::from_str(&zip_text(archive, path)?)
2704 .map_err(|err| format!("{path} is invalid JSON: {err}"))
2705}
2706
2707#[cfg(feature = "pack-zip")]
2708fn read_ontology_ir<R: Read + Seek>(
2709 archive: &mut ZipArchive<R>,
2710 path: &str,
2711) -> Result<OntologyModelIr, String> {
2712 let bytes = zip_bytes(archive, path)?;
2713 ciborium::de::from_reader(Cursor::new(bytes))
2714 .map_err(|err| format!("{path} is invalid ontology IR CBOR: {err}"))
2715}
2716
2717#[cfg(feature = "pack-zip")]
2718fn read_ontology_graph<R: Read + Seek>(
2719 archive: &mut ZipArchive<R>,
2720 path: &str,
2721) -> Result<serde_json::Value, String> {
2722 let graph: serde_json::Value = serde_json::from_str(&zip_text(archive, path)?)
2723 .map_err(|err| format!("{path} is invalid ontology graph JSON: {err}"))?;
2724 if graph["schema"].as_str() != Some(ONTOLOGY_GRAPH_SCHEMA) {
2725 return Err(format!(
2726 "{path} has unsupported ontology graph schema `{}`",
2727 graph["schema"].as_str().unwrap_or("<missing>")
2728 ));
2729 }
2730 Ok(graph)
2731}
2732
2733#[cfg(feature = "pack-zip")]
2734fn read_retrieval_ir<R: Read + Seek>(
2735 archive: &mut ZipArchive<R>,
2736 path: &str,
2737) -> Result<RetrievalBindingsIr, String> {
2738 let bytes = zip_bytes(archive, path)?;
2739 ciborium::de::from_reader(Cursor::new(bytes))
2740 .map_err(|err| format!("{path} is invalid retrieval bindings CBOR: {err}"))
2741}
2742
2743#[cfg(feature = "pack-zip")]
2744fn read_retrieval_json<R: Read + Seek>(
2745 archive: &mut ZipArchive<R>,
2746 path: &str,
2747) -> Result<RetrievalBindingsIr, String> {
2748 let retrieval: RetrievalBindingsIr = serde_json::from_str(&zip_text(archive, path)?)
2749 .map_err(|err| format!("{path} is invalid retrieval bindings JSON: {err}"))?;
2750 if retrieval.schema != RETRIEVAL_BINDINGS_SCHEMA {
2751 return Err(format!(
2752 "{path} has unsupported retrieval bindings schema `{}`",
2753 retrieval.schema
2754 ));
2755 }
2756 Ok(retrieval)
2757}
2758
2759#[cfg(feature = "pack-zip")]
2760fn read_designer_node_types<R: Read + Seek>(
2761 archive: &mut ZipArchive<R>,
2762 path: &str,
2763) -> Result<DesignerNodeTypesDocument, String> {
2764 let document: DesignerNodeTypesDocument = serde_json::from_str(&zip_text(archive, path)?)
2765 .map_err(|err| format!("{path} is invalid designer node types JSON: {err}"))?;
2766 if document.schema != DESIGNER_NODE_TYPES_SCHEMA {
2767 return Err(format!(
2768 "{path} has unsupported designer node types schema `{}`",
2769 document.schema
2770 ));
2771 }
2772 Ok(document)
2773}
2774
2775#[cfg(feature = "pack-zip")]
2776fn read_agent_endpoint_action_catalog<R: Read + Seek>(
2777 archive: &mut ZipArchive<R>,
2778 path: &str,
2779) -> Result<AgentEndpointActionCatalogDocument, String> {
2780 let document: AgentEndpointActionCatalogDocument =
2781 serde_json::from_str(&zip_text(archive, path)?).map_err(|err| {
2782 format!("{path} is invalid agent endpoint action catalog JSON: {err}")
2783 })?;
2784 if document.schema != AGENT_ENDPOINT_ACTION_CATALOG_SCHEMA {
2785 return Err(format!(
2786 "{path} has unsupported agent endpoint action catalog schema `{}`",
2787 document.schema
2788 ));
2789 }
2790 Ok(document)
2791}
2792
2793#[cfg(feature = "pack-zip")]
2794fn validate_lock_includes_entry<R: Read + Seek>(
2795 archive: &mut ZipArchive<R>,
2796 path: &str,
2797) -> Result<(), String> {
2798 let lock_bytes = zip_bytes(archive, "pack.lock.cbor")?;
2799 let lock: SorlaPackLock = ciborium::de::from_reader(Cursor::new(lock_bytes))
2800 .map_err(|err| format!("pack.lock.cbor is invalid CBOR: {err}"))?;
2801 if !lock.entries.contains_key(path) {
2802 return Err(format!(
2803 "pack.lock.cbor is missing validation asset `{path}`"
2804 ));
2805 }
2806 Ok(())
2807}
2808
2809#[cfg(feature = "pack-zip")]
2810pub fn doctor_sorla_gtpack(path: &Path) -> Result<SorlaGtpackDoctorReport, String> {
2811 let inspection = inspect_sorla_gtpack(path)?;
2812 let mut archive = open_gtpack(path)?;
2813 let names = zip_entry_names(&mut archive)?;
2814 for required in required_pack_entries() {
2815 if !names.contains(required) {
2816 return Err(format!("gtpack is missing required entry `{required}`"));
2817 }
2818 }
2819
2820 validate_pack_lock_entries(&mut archive, &names)?;
2821
2822 let gateway: AgentGatewayHandoffManifest =
2823 serde_json::from_str(&zip_text(&mut archive, "assets/sorla/agent-gateway.json")?)
2824 .map_err(|err| format!("agent-gateway.json is invalid JSON: {err}"))?;
2825 let model_bytes = zip_bytes(&mut archive, "assets/sorla/model.cbor")?;
2826 let ir: CanonicalIr = ciborium::de::from_reader(Cursor::new(model_bytes))
2827 .map_err(|err| format!("model.cbor is invalid canonical IR: {err}"))?;
2828 validate_embedded_sorx_validation(&mut archive, &names, &ir)?;
2829 validate_embedded_sorx_exposure_policy(&mut archive, &names, &ir)?;
2830 validate_embedded_sorx_compatibility(&mut archive, &names, &ir)?;
2831 validate_embedded_ontology_artifacts(&mut archive, &names, &ir)?;
2832 validate_embedded_retrieval_bindings(&mut archive, &names, &ir)?;
2833 validate_embedded_designer_node_types(&mut archive, &names, &ir)?;
2834 validate_embedded_agent_endpoint_action_catalog(&mut archive, &names, &ir)?;
2835 let endpoint_ids: BTreeSet<_> = ir
2836 .agent_endpoints
2837 .iter()
2838 .map(|endpoint| endpoint.id.as_str())
2839 .collect();
2840 for endpoint in &gateway.endpoints {
2841 if !endpoint_ids.contains(endpoint.id.as_str()) {
2842 return Err(format!(
2843 "agent-gateway.json references unknown endpoint `{}`",
2844 endpoint.id
2845 ));
2846 }
2847 }
2848
2849 if names.contains(&format!("assets/sorla/{MCP_TOOLS_FILENAME}")) {
2850 let mcp: serde_json::Value = serde_json::from_str(&zip_text(
2851 &mut archive,
2852 &format!("assets/sorla/{MCP_TOOLS_FILENAME}"),
2853 )?)
2854 .map_err(|err| format!("mcp-tools.json is invalid JSON: {err}"))?;
2855 for tool in mcp
2856 .get("tools")
2857 .and_then(serde_json::Value::as_array)
2858 .into_iter()
2859 .flatten()
2860 {
2861 let name = tool
2862 .get("name")
2863 .and_then(serde_json::Value::as_str)
2864 .ok_or_else(|| "mcp-tools.json has a tool without a name".to_string())?;
2865 if !endpoint_ids.contains(name) {
2866 return Err(format!(
2867 "mcp-tools.json references unknown endpoint `{name}`"
2868 ));
2869 }
2870 }
2871 }
2872
2873 let startup_schema: serde_json::Value =
2874 serde_json::from_str(&zip_text(&mut archive, "assets/sorx/start.schema.json")?)
2875 .map_err(|err| format!("start.schema.json is invalid JSON: {err}"))?;
2876 for required in [
2877 "tenant.tenant_id",
2878 "server.bind",
2879 "server.public_base_url",
2880 "providers.store.kind",
2881 "providers.store.config_ref",
2882 "policy.approvals.high",
2883 "audit.sink",
2884 ] {
2885 let has_required = startup_schema
2886 .get("required")
2887 .and_then(serde_json::Value::as_array)
2888 .is_some_and(|items| items.iter().any(|item| item.as_str() == Some(required)));
2889 if !has_required {
2890 return Err(format!(
2891 "start.schema.json is missing required path `{required}`"
2892 ));
2893 }
2894 }
2895
2896 reject_secret_markers(&mut archive)?;
2897
2898 let mut checked_assets = required_pack_entries()
2899 .into_iter()
2900 .map(str::to_string)
2901 .collect::<Vec<_>>();
2902 if inspection.ontology.is_some() {
2903 checked_assets.extend(
2904 [
2905 ONTOLOGY_GRAPH_PATH,
2906 ONTOLOGY_IR_CBOR_PATH,
2907 ONTOLOGY_SCHEMA_PATH,
2908 ]
2909 .into_iter()
2910 .map(str::to_string),
2911 );
2912 }
2913 if inspection.retrieval_bindings.is_some() {
2914 checked_assets.extend(
2915 [RETRIEVAL_BINDINGS_PATH, RETRIEVAL_BINDINGS_IR_CBOR_PATH]
2916 .into_iter()
2917 .map(str::to_string),
2918 );
2919 }
2920 if inspection.designer_node_types.is_some() {
2921 checked_assets.push(DESIGNER_NODE_TYPES_PATH.to_string());
2922 }
2923 if inspection.agent_endpoint_action_catalog.is_some() {
2924 checked_assets.push(AGENT_ENDPOINT_ACTION_CATALOG_PATH.to_string());
2925 }
2926
2927 Ok(SorlaGtpackDoctorReport {
2928 path: inspection.path,
2929 status: "ok".to_string(),
2930 checked_assets,
2931 })
2932}
2933
2934#[cfg(feature = "pack-zip")]
2935fn required_pack_entries() -> Vec<&'static str> {
2936 vec![
2937 "pack.cbor",
2938 "pack.lock.cbor",
2939 "manifest.cbor",
2940 "assets/sorla/model.cbor",
2941 "assets/sorla/package-manifest.cbor",
2942 "assets/sorla/executable-contract.json",
2943 "assets/sorla/agent-gateway.json",
2944 "assets/sorx/start.schema.json",
2945 "assets/sorx/start.questions.cbor",
2946 "assets/sorx/runtime.template.yaml",
2947 "assets/sorx/provider-bindings.template.yaml",
2948 SORX_COMPATIBILITY_PATH,
2949 SORX_EXPOSURE_POLICY_PATH,
2950 SORX_VALIDATION_MANIFEST_PATH,
2951 ]
2952}
2953
2954#[cfg(feature = "pack-zip")]
2955fn open_gtpack(path: &Path) -> Result<ZipArchive<fs::File>, String> {
2956 let file = fs::File::open(path)
2957 .map_err(|err| format!("failed to open gtpack {}: {err}", path.display()))?;
2958 ZipArchive::new(file).map_err(|err| format!("failed to read gtpack {}: {err}", path.display()))
2959}
2960
2961#[cfg(feature = "pack-zip")]
2962fn zip_entry_names<R: Read + Seek>(
2963 archive: &mut ZipArchive<R>,
2964) -> Result<BTreeSet<String>, String> {
2965 let mut names = BTreeSet::new();
2966 for index in 0..archive.len() {
2967 let entry = archive
2968 .by_index(index)
2969 .map_err(|err| format!("failed to inspect gtpack entry {index}: {err}"))?;
2970 if !entry.is_dir() {
2971 names.insert(entry.name().to_string());
2972 }
2973 }
2974 Ok(names)
2975}
2976
2977#[cfg(feature = "pack-zip")]
2978fn zip_bytes<R: Read + Seek>(archive: &mut ZipArchive<R>, name: &str) -> Result<Vec<u8>, String> {
2979 let mut entry = archive
2980 .by_name(name)
2981 .map_err(|err| format!("gtpack is missing `{name}`: {err}"))?;
2982 let mut bytes = Vec::new();
2983 entry
2984 .read_to_end(&mut bytes)
2985 .map_err(|err| format!("failed to read `{name}`: {err}"))?;
2986 Ok(bytes)
2987}
2988
2989#[cfg(feature = "pack-zip")]
2990fn zip_text<R: Read + Seek>(archive: &mut ZipArchive<R>, name: &str) -> Result<String, String> {
2991 let bytes = zip_bytes(archive, name)?;
2992 String::from_utf8(bytes).map_err(|err| format!("`{name}` is not UTF-8: {err}"))
2993}
2994
2995#[cfg(feature = "pack-zip")]
2996fn validate_pack_lock_entries<R: Read + Seek>(
2997 archive: &mut ZipArchive<R>,
2998 names: &BTreeSet<String>,
2999) -> Result<(), String> {
3000 let lock_bytes = zip_bytes(archive, "pack.lock.cbor")?;
3001 let lock: SorlaPackLock = ciborium::de::from_reader(Cursor::new(lock_bytes))
3002 .map_err(|err| format!("pack.lock.cbor is invalid CBOR: {err}"))?;
3003 if lock.schema != "greentic.gtpack.lock.sorla.v1" {
3004 return Err(format!(
3005 "pack.lock.cbor has unsupported schema `{}`",
3006 lock.schema
3007 ));
3008 }
3009 for (path, expected) in &lock.entries {
3010 if !names.contains(path) {
3011 return Err(format!("pack.lock.cbor references missing entry `{path}`"));
3012 }
3013 let bytes = zip_bytes(archive, path)?;
3014 if expected.size != bytes.len() as u64 {
3015 return Err(format!("pack.lock.cbor size mismatch for `{path}`"));
3016 }
3017 let actual = sha256_hex(&bytes);
3018 if expected.sha256 != actual {
3019 return Err(format!("pack.lock.cbor digest mismatch for `{path}`"));
3020 }
3021 }
3022 Ok(())
3023}
3024
3025#[cfg(feature = "pack-zip")]
3026fn reject_secret_markers<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Result<(), String> {
3027 const MARKERS: &[&str] = &[
3028 "BEGIN PRIVATE KEY",
3029 "api_key:",
3030 "access_token:",
3031 "refresh_token:",
3032 "client_secret:",
3033 "password:",
3034 ];
3035 let names = zip_entry_names(archive)?;
3036 for name in names {
3037 let bytes = zip_bytes(archive, &name)?;
3038 let text = String::from_utf8_lossy(&bytes).to_ascii_lowercase();
3039 for marker in MARKERS {
3040 if text.contains(&marker.to_ascii_lowercase()) {
3041 return Err(format!(
3042 "gtpack entry `{name}` appears to contain `{marker}`"
3043 ));
3044 }
3045 }
3046 }
3047 Ok(())
3048}
3049
3050pub fn executable_contract_json(ir: &CanonicalIr) -> String {
3051 let relationships: Vec<_> = ir
3052 .records
3053 .iter()
3054 .flat_map(|record| {
3055 record.fields.iter().filter_map(move |field| {
3056 field.references.as_ref().map(|reference| {
3057 serde_json::json!({
3058 "record": record.name,
3059 "field": field.name,
3060 "references": {
3061 "record": reference.record,
3062 "field": reference.field
3063 }
3064 })
3065 })
3066 })
3067 })
3068 .collect();
3069
3070 let migrations: Vec<_> = ir
3071 .compatibility
3072 .iter()
3073 .map(|migration| {
3074 serde_json::json!({
3075 "name": migration.name,
3076 "compatibility": migration.compatibility,
3077 "projection_updates": migration.projection_updates,
3078 "backfills": migration.backfills,
3079 "idempotence_key": migration.idempotence_key
3080 })
3081 })
3082 .collect();
3083
3084 let agent_operations: Vec<_> = ir
3085 .agent_endpoints
3086 .iter()
3087 .filter_map(|endpoint| {
3088 endpoint.emits.as_ref().map(|emit| {
3089 serde_json::json!({
3090 "endpoint_id": endpoint.id,
3091 "emits": emit
3092 })
3093 })
3094 })
3095 .collect();
3096
3097 serde_json::to_string_pretty(&serde_json::json!({
3098 "schema": "greentic.sorla.executable-contract.v1",
3099 "package": {
3100 "name": ir.package.name,
3101 "version": ir.package.version,
3102 "ir_hash": canonical_hash_hex(ir)
3103 },
3104 "relationships": relationships,
3105 "migrations": migrations,
3106 "agent_operations": agent_operations,
3107 "operation_result_contract": {
3108 "schema": "greentic.sorla.operation-result.v1",
3109 "fields": {
3110 "endpoint_id": "string",
3111 "status": ["ok", "validation_error", "provider_error"],
3112 "data": "object",
3113 "errors": [
3114 {
3115 "path": "string",
3116 "code": "string",
3117 "message": "string"
3118 }
3119 ],
3120 "provider_message": "string"
3121 }
3122 }
3123 }))
3124 .expect("executable contract should serialize")
3125}
3126
3127pub fn agent_gateway_handoff_manifest(ir: &CanonicalIr) -> AgentGatewayHandoffManifest {
3128 let endpoints: Vec<AgentGatewayEndpointRef> = ir
3129 .agent_endpoints
3130 .iter()
3131 .map(|endpoint| AgentGatewayEndpointRef {
3132 id: endpoint.id.clone(),
3133 title: endpoint.title.clone(),
3134 intent: endpoint.intent.clone(),
3135 risk: agent_endpoint_risk_label(&endpoint.risk).to_string(),
3136 approval: agent_endpoint_approval_label(&endpoint.approval).to_string(),
3137 inputs: endpoint
3138 .inputs
3139 .iter()
3140 .map(|input| input.name.clone())
3141 .collect(),
3142 outputs: endpoint
3143 .outputs
3144 .iter()
3145 .map(|output| output.name.clone())
3146 .collect(),
3147 side_effects: endpoint.side_effects.clone(),
3148 exports: AgentGatewayEndpointExports {
3149 openapi: endpoint.agent_visibility.openapi,
3150 arazzo: endpoint.agent_visibility.arazzo,
3151 mcp: endpoint.agent_visibility.mcp,
3152 llms_txt: endpoint.agent_visibility.llms_txt,
3153 },
3154 })
3155 .collect();
3156
3157 let exports = AgentGatewayExports {
3158 agent_gateway_json: true,
3159 openapi_overlay: endpoints.iter().any(|endpoint| endpoint.exports.openapi),
3160 arazzo: endpoints.iter().any(|endpoint| endpoint.exports.arazzo),
3161 mcp_tools: endpoints.iter().any(|endpoint| endpoint.exports.mcp),
3162 llms_txt: endpoints.iter().any(|endpoint| endpoint.exports.llms_txt),
3163 };
3164
3165 AgentGatewayHandoffManifest {
3166 schema: AGENT_GATEWAY_HANDOFF_SCHEMA.to_string(),
3167 package: AgentGatewayPackageRef {
3168 name: ir.package.name.clone(),
3169 version: ir.package.version.clone(),
3170 ir_version: format!("{}.{}", ir.ir_version.major, ir.ir_version.minor),
3171 ir_hash: canonical_hash_hex(ir),
3172 },
3173 endpoints,
3174 provider_contract: AgentGatewayProviderContract {
3175 categories: aggregated_provider_requirements(ir),
3176 },
3177 exports,
3178 notes: vec![
3179 "This is handoff metadata for downstream assembly, not final runtime gateway behavior."
3180 .to_string(),
3181 ],
3182 }
3183}
3184
3185pub fn export_agent_artifacts(ir: &CanonicalIr) -> AgentExportSet {
3186 let manifest = agent_gateway_handoff_manifest(ir);
3187 let agent_gateway_json =
3188 serde_json::to_string_pretty(&manifest).expect("agent gateway manifest should serialize");
3189
3190 AgentExportSet {
3191 agent_gateway_json,
3192 openapi_overlay_yaml: (!visible_openapi_endpoints(ir).is_empty())
3193 .then(|| openapi_overlay_yaml(ir)),
3194 arazzo_yaml: (!visible_arazzo_endpoints(ir).is_empty()).then(|| arazzo_yaml(ir)),
3195 mcp_tools_json: (!visible_mcp_endpoints(ir).is_empty()).then(|| mcp_tools_json(ir)),
3196 llms_txt: (!visible_llms_txt_endpoints(ir).is_empty()).then(|| llms_txt_fragment(ir)),
3197 }
3198}
3199
3200pub fn build_artifacts_from_yaml(input: &str) -> Result<ArtifactSet, String> {
3201 build_handoff_artifacts_from_yaml(input)
3202}
3203
3204fn provider_view(requirement: &ProviderRequirementIr) -> ProviderRequirementView {
3205 ProviderRequirementView {
3206 category: requirement.category.clone(),
3207 capabilities: requirement.capabilities.clone(),
3208 }
3209}
3210
3211fn aggregated_provider_requirements(ir: &CanonicalIr) -> Vec<AgentGatewayProviderRequirement> {
3212 let mut categories: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
3213 for requirement in &ir.provider_contract.categories {
3214 insert_provider_requirement(&mut categories, requirement);
3215 }
3216 for endpoint in &ir.agent_endpoints {
3217 for requirement in &endpoint.provider_requirements {
3218 insert_provider_requirement(&mut categories, requirement);
3219 }
3220 }
3221
3222 categories
3223 .into_iter()
3224 .map(|(category, capabilities)| AgentGatewayProviderRequirement {
3225 category,
3226 capabilities: capabilities.into_iter().collect(),
3227 })
3228 .collect()
3229}
3230
3231fn insert_provider_requirement(
3232 categories: &mut BTreeMap<String, BTreeSet<String>>,
3233 requirement: &ProviderRequirementIr,
3234) {
3235 categories
3236 .entry(requirement.category.clone())
3237 .or_default()
3238 .extend(requirement.capabilities.iter().cloned());
3239}
3240
3241fn agent_endpoint_risk_label(risk: &AgentEndpointRiskIr) -> &'static str {
3242 match risk {
3243 AgentEndpointRiskIr::Low => "low",
3244 AgentEndpointRiskIr::Medium => "medium",
3245 AgentEndpointRiskIr::High => "high",
3246 }
3247}
3248
3249fn agent_endpoint_approval_label(approval: &AgentEndpointApprovalModeIr) -> &'static str {
3250 match approval {
3251 AgentEndpointApprovalModeIr::None => "none",
3252 AgentEndpointApprovalModeIr::Optional => "optional",
3253 AgentEndpointApprovalModeIr::Required => "required",
3254 AgentEndpointApprovalModeIr::PolicyDriven => "policy-driven",
3255 }
3256}
3257
3258fn visible_openapi_endpoints(ir: &CanonicalIr) -> Vec<&AgentEndpointIr> {
3259 ir.agent_endpoints
3260 .iter()
3261 .filter(|endpoint| endpoint.agent_visibility.openapi)
3262 .collect()
3263}
3264
3265fn visible_arazzo_endpoints(ir: &CanonicalIr) -> Vec<&AgentEndpointIr> {
3266 ir.agent_endpoints
3267 .iter()
3268 .filter(|endpoint| endpoint.agent_visibility.arazzo)
3269 .collect()
3270}
3271
3272fn visible_mcp_endpoints(ir: &CanonicalIr) -> Vec<&AgentEndpointIr> {
3273 ir.agent_endpoints
3274 .iter()
3275 .filter(|endpoint| endpoint.agent_visibility.mcp)
3276 .collect()
3277}
3278
3279fn visible_llms_txt_endpoints(ir: &CanonicalIr) -> Vec<&AgentEndpointIr> {
3280 ir.agent_endpoints
3281 .iter()
3282 .filter(|endpoint| endpoint.agent_visibility.llms_txt)
3283 .collect()
3284}
3285
3286fn openapi_overlay_yaml(ir: &CanonicalIr) -> String {
3287 let operations = visible_openapi_endpoints(ir)
3288 .into_iter()
3289 .map(|endpoint| {
3290 serde_json::json!({
3291 "operationId": format!("agent_{}", endpoint.id),
3292 "x-greentic-agent": {
3293 "endpoint_id": endpoint.id,
3294 "intent": endpoint.intent,
3295 "risk": agent_endpoint_risk_label(&endpoint.risk),
3296 "approval": agent_endpoint_approval_label(&endpoint.approval),
3297 "side_effects": endpoint.side_effects,
3298 "inputs": endpoint.inputs.iter().map(openapi_input_value).collect::<Vec<_>>(),
3299 "outputs": endpoint.outputs.iter().map(output_value).collect::<Vec<_>>()
3300 }
3301 })
3302 })
3303 .collect::<Vec<_>>();
3304
3305 serialize_yaml(serde_json::json!({
3306 "schema": OPENAPI_AGENT_OVERLAY_SCHEMA,
3307 "package": ir.package.name,
3308 "operations": operations
3309 }))
3310}
3311
3312fn arazzo_yaml(ir: &CanonicalIr) -> String {
3313 let workflows = visible_arazzo_endpoints(ir)
3314 .into_iter()
3315 .map(|endpoint| {
3316 serde_json::json!({
3317 "workflowId": endpoint.id,
3318 "summary": endpoint.title,
3319 "description": endpoint.intent,
3320 "inputs": object_schema_value(&endpoint.inputs),
3321 "steps": [
3322 {
3323 "stepId": format!("request_{}", endpoint.id),
3324 "description": format!("Request downstream Greentic execution for {}.", endpoint.id)
3325 }
3326 ]
3327 })
3328 })
3329 .collect::<Vec<_>>();
3330
3331 serialize_yaml(serde_json::json!({
3332 "arazzo": "1.0.1",
3333 "info": {
3334 "title": format!("{} agent workflows", ir.package.name),
3335 "version": ir.package.version
3336 },
3337 "sourceDescriptions": [],
3338 "workflows": workflows
3339 }))
3340}
3341
3342fn mcp_tools_json(ir: &CanonicalIr) -> String {
3343 let tools = visible_mcp_endpoints(ir)
3344 .into_iter()
3345 .map(|endpoint| {
3346 serde_json::json!({
3347 "name": endpoint.id,
3348 "title": endpoint.title,
3349 "description": endpoint.intent,
3350 "inputSchema": object_schema_value(&endpoint.inputs),
3351 "annotations": {
3352 "risk": agent_endpoint_risk_label(&endpoint.risk),
3353 "approval": agent_endpoint_approval_label(&endpoint.approval),
3354 "side_effects": endpoint.side_effects
3355 }
3356 })
3357 })
3358 .collect::<Vec<_>>();
3359
3360 serde_json::to_string_pretty(&serde_json::json!({
3361 "schema": MCP_TOOLS_HANDOFF_SCHEMA,
3362 "tools": tools
3363 }))
3364 .expect("MCP tools handoff should serialize")
3365}
3366
3367fn llms_txt_fragment(ir: &CanonicalIr) -> String {
3368 let mut lines = vec![
3369 format!("# {} agent endpoints", ir.package.name),
3370 String::new(),
3371 "This package exposes handoff metadata for business-safe agent endpoints.".to_string(),
3372 ];
3373
3374 for endpoint in visible_llms_txt_endpoints(ir) {
3375 lines.push(String::new());
3376 lines.push(format!("## {}", endpoint.id));
3377 lines.push(String::new());
3378 lines.push(format!("Intent: {}", endpoint.intent));
3379 lines.push(format!(
3380 "Risk: {}",
3381 agent_endpoint_risk_label(&endpoint.risk)
3382 ));
3383 lines.push(format!(
3384 "Approval: {}",
3385 agent_endpoint_approval_label(&endpoint.approval)
3386 ));
3387 lines.push(format!(
3388 "Side effects: {}",
3389 join_or_none(&endpoint.side_effects)
3390 ));
3391 lines.push(format!(
3392 "Required inputs: {}",
3393 join_or_none(
3394 &endpoint
3395 .inputs
3396 .iter()
3397 .filter(|input| input.required)
3398 .map(|input| input.name.clone())
3399 .collect::<Vec<_>>()
3400 )
3401 ));
3402 lines.push(format!(
3403 "Outputs: {}",
3404 join_or_none(
3405 &endpoint
3406 .outputs
3407 .iter()
3408 .map(|output| output.name.clone())
3409 .collect::<Vec<_>>()
3410 )
3411 ));
3412 }
3413
3414 lines.join("\n") + "\n"
3415}
3416
3417fn openapi_input_value(input: &AgentEndpointInputIr) -> serde_json::Value {
3418 serde_json::json!({
3419 "name": input.name,
3420 "type": input.type_name,
3421 "required": input.required,
3422 "sensitive": input.sensitive
3423 })
3424}
3425
3426fn output_value(output: &AgentEndpointOutputIr) -> serde_json::Value {
3427 serde_json::json!({
3428 "name": output.name,
3429 "type": output.type_name
3430 })
3431}
3432
3433fn object_schema_value(inputs: &[AgentEndpointInputIr]) -> serde_json::Value {
3434 let required = inputs
3435 .iter()
3436 .filter(|input| input.required)
3437 .map(|input| input.name.clone())
3438 .collect::<Vec<_>>();
3439 let properties = inputs
3440 .iter()
3441 .map(|input| {
3442 let description = input
3443 .sensitive
3444 .then_some("Sensitive input")
3445 .or(input.description.as_deref());
3446 let mut property = serde_json::Map::new();
3447 property.insert(
3448 "type".to_string(),
3449 serde_json::Value::String(input.type_name.clone()),
3450 );
3451 if let Some(description) = description {
3452 property.insert(
3453 "description".to_string(),
3454 serde_json::Value::String(description.to_string()),
3455 );
3456 }
3457 if !input.enum_values.is_empty() {
3458 property.insert("enum".to_string(), serde_json::json!(input.enum_values));
3459 }
3460 (input.name.clone(), serde_json::Value::Object(property))
3461 })
3462 .collect::<serde_json::Map<_, _>>();
3463
3464 serde_json::json!({
3465 "type": "object",
3466 "required": required,
3467 "properties": properties
3468 })
3469}
3470
3471fn serialize_yaml(value: serde_json::Value) -> String {
3472 serde_yaml::to_string(&value).expect("agent YAML handoff should serialize")
3473}
3474
3475fn join_or_none(values: &[String]) -> String {
3476 if values.is_empty() {
3477 "none".to_string()
3478 } else {
3479 values.join(", ")
3480 }
3481}
3482
3483#[cfg(test)]
3484mod tests {
3485 use super::*;
3486 use std::fs;
3487 use std::io::Read;
3488 use tempfile::tempdir;
3489 use zip::ZipArchive;
3490
3491 #[test]
3492 fn scaffold_handoff_manifest_stays_provider_agnostic() {
3493 let manifest = scaffold_handoff_manifest();
3494 assert_eq!(manifest.package_kind, "sorla-package");
3495 assert_eq!(manifest.provider_repo, "greentic-sorla-providers");
3496 assert!(
3497 manifest
3498 .artifact_references
3499 .contains(&"provider-contract.cbor".to_string())
3500 );
3501 }
3502
3503 #[test]
3504 fn legacy_manifest_api_maps_to_handoff_manifest() {
3505 let manifest = scaffold_manifest();
3506 assert_eq!(manifest.package_kind, "sorla-package");
3507 assert!(
3508 manifest
3509 .artifact_references
3510 .contains(&"package-manifest.cbor".to_string())
3511 );
3512 assert!(
3513 manifest
3514 .artifact_references
3515 .contains(&"launcher-handoff.cbor".to_string())
3516 );
3517 }
3518
3519 #[test]
3520 fn builds_deterministic_handoff_artifacts_for_golden_fixture() {
3521 let fixture = fs::read_to_string("tests/golden/tenant_v0_2.sorla.yaml")
3522 .expect("fixture should be readable");
3523 let expected_inspect = fs::read_to_string("tests/golden/tenant_v0_2.inspect.json")
3524 .expect("golden should be readable");
3525
3526 let first = build_handoff_artifacts_from_yaml(&fixture).expect("fixture should build");
3527 let second = build_handoff_artifacts_from_yaml(&fixture).expect("fixture should build");
3528
3529 assert_eq!(first.inspect_json.trim_end(), expected_inspect.trim_end());
3530 assert_eq!(first.inspect_json, second.inspect_json);
3531 assert_eq!(first.canonical_hash, second.canonical_hash);
3532 assert!(first.cbor_artifacts.contains_key("model.cbor"));
3533 assert!(first.cbor_artifacts.contains_key("events.cbor"));
3534 assert!(first.cbor_artifacts.contains_key("projections.cbor"));
3535 assert!(first.cbor_artifacts.contains_key("provider-contract.cbor"));
3536 assert!(
3537 !first
3538 .cbor_artifacts
3539 .contains_key(AGENT_ENDPOINTS_IR_CBOR_FILENAME)
3540 );
3541 assert!(first.cbor_artifacts.contains_key("launcher-handoff.cbor"));
3542 assert!(first.agent_exports.agent_gateway_json.contains(
3543 "This is handoff metadata for downstream assembly, not final runtime gateway behavior."
3544 ));
3545 assert!(first.agent_tools_json.contains("storage"));
3546 assert_eq!(
3547 first.handoff_manifest().provider_repo,
3548 "greentic-sorla-providers"
3549 );
3550 }
3551
3552 #[test]
3553 fn legacy_artifact_builder_maps_to_handoff_builder() {
3554 let fixture = fs::read_to_string("tests/golden/tenant_v0_2.sorla.yaml")
3555 .expect("fixture should be readable");
3556 let artifacts = build_artifacts_from_yaml(&fixture).expect("fixture should build");
3557 assert!(
3558 artifacts
3559 .cbor_artifacts
3560 .contains_key("package-manifest.cbor")
3561 );
3562 assert!(
3563 artifacts
3564 .cbor_artifacts
3565 .contains_key("launcher-handoff.cbor")
3566 );
3567 }
3568
3569 #[test]
3570 fn builds_agent_endpoint_fixture_end_to_end() {
3571 let fixture =
3572 fs::read_to_string("tests/golden/customer_contact_agent_endpoints.sorla.yaml")
3573 .expect("agent endpoint fixture should be readable");
3574 let expected_inspect =
3575 fs::read_to_string("tests/golden/customer_contact_agent_endpoints.inspect.json")
3576 .expect("agent endpoint inspect golden should be readable");
3577 let expected_gateway =
3578 fs::read_to_string("tests/golden/customer_contact_agent_endpoints.agent-gateway.json")
3579 .expect("agent gateway golden should be readable");
3580
3581 let parsed = parse_package(&fixture).expect("agent endpoint fixture should parse");
3582 let ir = lower_package(&parsed.package);
3583 let first_exports = export_agent_artifacts(&ir);
3584 let second_exports = export_agent_artifacts(&ir);
3585 let manifest = agent_gateway_handoff_manifest(&ir);
3586 let built =
3587 build_artifacts_from_yaml(&fixture).expect("agent endpoint fixture should build");
3588
3589 assert_eq!(inspect_ir(&ir).trim_end(), expected_inspect.trim_end());
3590 assert_eq!(first_exports, second_exports);
3591 assert_eq!(manifest.package.ir_hash, canonical_hash_hex(&ir));
3592 assert!(
3593 built
3594 .cbor_artifacts
3595 .contains_key(AGENT_ENDPOINTS_IR_CBOR_FILENAME)
3596 );
3597 assert_eq!(
3598 built
3599 .cbor_artifacts
3600 .get(AGENT_ENDPOINTS_IR_CBOR_FILENAME)
3601 .expect("agent endpoint IR CBOR should be emitted"),
3602 &canonical_cbor(&ir)
3603 );
3604 for artifact in [
3605 AGENT_GATEWAY_HANDOFF_FILENAME,
3606 AGENT_ENDPOINTS_IR_CBOR_FILENAME,
3607 AGENT_OPENAPI_OVERLAY_FILENAME,
3608 AGENT_ARAZZO_FILENAME,
3609 MCP_TOOLS_FILENAME,
3610 LLMS_TXT_FRAGMENT_FILENAME,
3611 DESIGNER_NODE_TYPES_FILENAME,
3612 AGENT_ENDPOINT_ACTION_CATALOG_FILENAME,
3613 ] {
3614 assert!(
3615 built
3616 .package_manifest
3617 .artifact_references
3618 .contains(&artifact.to_string()),
3619 "expected package manifest to reference {artifact}"
3620 );
3621 }
3622 assert_eq!(built.agent_exports, first_exports);
3623 let designer_node_types: DesignerNodeTypesDocument =
3624 serde_json::from_str(&built.designer_node_types_json)
3625 .expect("designer node types should parse");
3626 assert_eq!(designer_node_types.schema, DESIGNER_NODE_TYPES_SCHEMA);
3627 assert_eq!(
3628 designer_node_types.node_types.len(),
3629 ir.agent_endpoints.len()
3630 );
3631 assert_eq!(
3632 designer_node_types.node_types[0]
3633 .metadata
3634 .endpoint
3635 .contract_hash,
3636 format!("sha256:{}", canonical_hash_hex(&ir))
3637 );
3638 assert_eq!(
3639 designer_node_types.node_types[0].binding.operation,
3640 DEFAULT_DESIGNER_COMPONENT_OPERATION
3641 );
3642 let email_field = designer_node_types.node_types[0]
3643 .ui
3644 .fields
3645 .iter()
3646 .find(|field| field.name == "email")
3647 .expect("email field should be emitted");
3648 assert_eq!(email_field.label, "Email");
3649 assert_eq!(email_field.widget, "text");
3650 assert!(
3651 designer_node_types.node_types[0]
3652 .ui
3653 .aliases
3654 .contains(&"create customer contact".to_string())
3655 );
3656 let action_catalog: AgentEndpointActionCatalogDocument =
3657 serde_json::from_str(&built.agent_endpoint_action_catalog_json)
3658 .expect("action catalog should parse");
3659 assert_eq!(action_catalog.schema, AGENT_ENDPOINT_ACTION_CATALOG_SCHEMA);
3660 assert_eq!(action_catalog.actions.len(), ir.agent_endpoints.len());
3661 assert_eq!(
3662 action_catalog.actions[0].endpoint_ref.contract_hash,
3663 format!("sha256:{}", canonical_hash_hex(&ir))
3664 );
3665 assert!(
3666 built
3667 .package_manifest
3668 .artifact_references
3669 .contains(&DESIGNER_NODE_TYPES_FILENAME.to_string())
3670 );
3671 assert!(
3672 built
3673 .package_manifest
3674 .artifact_references
3675 .contains(&AGENT_ENDPOINT_ACTION_CATALOG_FILENAME.to_string())
3676 );
3677
3678 let mut expected_gateway_value: serde_json::Value =
3679 serde_json::from_str(&expected_gateway).expect("agent gateway golden should parse");
3680 expected_gateway_value["package"]["ir_hash"] =
3681 serde_json::Value::String(canonical_hash_hex(&ir));
3682 let actual_gateway_value: serde_json::Value = serde_json::from_str(
3683 &serde_json::to_string_pretty(&manifest).expect("manifest should serialize"),
3684 )
3685 .expect("manifest JSON should parse");
3686 assert_eq!(actual_gateway_value, expected_gateway_value);
3687
3688 assert!(first_exports.openapi_overlay_yaml.is_some());
3689 assert!(first_exports.arazzo_yaml.is_some());
3690 assert!(first_exports.mcp_tools_json.is_some());
3691 assert!(first_exports.llms_txt.is_some());
3692 assert!(
3693 first_exports
3694 .llms_txt
3695 .as_deref()
3696 .expect("llms.txt fragment should be generated")
3697 .contains("Capture a customer enquiry")
3698 );
3699 }
3700
3701 #[test]
3702 fn builds_deterministic_landlord_tenant_gtpack() {
3703 let temp = tempdir().expect("tempdir");
3704 let input = PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml");
3705 let first_out = temp.path().join("first.gtpack");
3706 let second_out = temp.path().join("second.gtpack");
3707 let options = |out_path: PathBuf| SorlaGtpackOptions {
3708 input_path: input.clone(),
3709 name: "landlord-tenant-sor".to_string(),
3710 version: "0.1.0".to_string(),
3711 out_path,
3712 };
3713
3714 let first = build_sorla_gtpack(&options(first_out.clone())).expect("first pack builds");
3715 let second = build_sorla_gtpack(&options(second_out.clone())).expect("second pack builds");
3716
3717 assert_eq!(first.name, "landlord-tenant-sor");
3718 assert_eq!(first.sorla_package_name, "landlord-tenant-sor");
3719 assert_eq!(
3720 fs::read(&first_out).unwrap(),
3721 fs::read(&second_out).unwrap()
3722 );
3723 assert_eq!(first.ir_hash, second.ir_hash);
3724 assert!(
3725 first
3726 .assets
3727 .contains(&"assets/sorla/model.cbor".to_string())
3728 );
3729 assert!(
3730 first
3731 .assets
3732 .contains(&format!("assets/sorla/{AGENT_GATEWAY_HANDOFF_FILENAME}"))
3733 );
3734 assert!(
3735 first
3736 .assets
3737 .contains(&format!("assets/sorla/{MCP_TOOLS_FILENAME}"))
3738 );
3739 assert!(first.assets.contains(&DESIGNER_NODE_TYPES_PATH.to_string()));
3740 assert!(
3741 first
3742 .assets
3743 .contains(&AGENT_ENDPOINT_ACTION_CATALOG_PATH.to_string())
3744 );
3745
3746 let inspection = inspect_sorla_gtpack(&first_out).expect("inspect pack");
3747 assert_eq!(inspection.extension, SORX_RUNTIME_EXTENSION_ID);
3748 assert_eq!(inspection.sorla_package_name, "landlord-tenant-sor");
3749 let validation = inspection
3750 .validation
3751 .as_ref()
3752 .expect("inspect should summarize validation manifest");
3753 assert_eq!(validation.schema, SORX_VALIDATION_SCHEMA);
3754 assert!(validation.suite_count >= 1);
3755 assert!(validation.test_count >= 1);
3756 let exposure = inspection
3757 .exposure_policy
3758 .as_ref()
3759 .expect("inspect should summarize exposure policy");
3760 assert_eq!(exposure.default_visibility, EndpointVisibility::Private);
3761 assert!(exposure.public_candidate_endpoints >= 1);
3762 assert!(exposure.approval_required_endpoints >= 1);
3763 let compatibility = inspection
3764 .compatibility
3765 .as_ref()
3766 .expect("inspect should summarize compatibility manifest");
3767 assert!(compatibility.provider_requirement_count >= 1);
3768 assert_eq!(
3769 compatibility.state_mode,
3770 StateCompatibilityMode::IsolatedRequired
3771 );
3772 assert_eq!(
3773 inspection
3774 .optional_artifacts
3775 .get(&format!("assets/sorla/{AGENT_OPENAPI_OVERLAY_FILENAME}")),
3776 Some(&true)
3777 );
3778 let designer = inspection
3779 .designer_node_types
3780 .as_ref()
3781 .expect("inspect should summarize designer node types");
3782 assert_eq!(designer.schema, DESIGNER_NODE_TYPES_SCHEMA);
3783 assert!(designer.count >= 1);
3784 let catalog = inspection
3785 .agent_endpoint_action_catalog
3786 .as_ref()
3787 .expect("inspect should summarize action catalog");
3788 assert_eq!(catalog.schema, AGENT_ENDPOINT_ACTION_CATALOG_SCHEMA);
3789 assert!(catalog.count >= 1);
3790 doctor_sorla_gtpack(&first_out).expect("doctor accepts pack");
3791
3792 let mut archive =
3793 ZipArchive::new(fs::File::open(&first_out).expect("open pack")).expect("read pack");
3794 for required in required_pack_entries() {
3795 archive.by_name(required).expect("required entry exists");
3796 }
3797 let pack_manifest: SorlaPackManifest = ciborium::de::from_reader(Cursor::new(
3798 zip_bytes(&mut archive, "pack.cbor").expect("pack.cbor"),
3799 ))
3800 .expect("pack manifest decodes");
3801 assert_eq!(
3802 pack_manifest
3803 .extension
3804 .get("sorx")
3805 .and_then(|sorx| sorx.get("validation_manifest"))
3806 .and_then(serde_json::Value::as_str),
3807 Some(SORX_VALIDATION_MANIFEST_PATH)
3808 );
3809 assert_eq!(
3810 pack_manifest
3811 .extension
3812 .get("sorx")
3813 .and_then(|sorx| sorx.get("exposure_policy"))
3814 .and_then(serde_json::Value::as_str),
3815 Some(SORX_EXPOSURE_POLICY_PATH)
3816 );
3817 assert_eq!(
3818 pack_manifest
3819 .extension
3820 .get("sorx")
3821 .and_then(|sorx| sorx.get("compatibility"))
3822 .and_then(serde_json::Value::as_str),
3823 Some(SORX_COMPATIBILITY_PATH)
3824 );
3825 assert!(
3826 pack_manifest
3827 .assets
3828 .contains(&SORX_VALIDATION_MANIFEST_PATH.to_string())
3829 );
3830 assert!(
3831 pack_manifest
3832 .assets
3833 .contains(&SORX_EXPOSURE_POLICY_PATH.to_string())
3834 );
3835 assert!(
3836 pack_manifest
3837 .assets
3838 .contains(&SORX_COMPATIBILITY_PATH.to_string())
3839 );
3840 }
3841
3842 #[test]
3843 fn designer_node_metadata_handles_labels_widgets_and_enums() {
3844 let yaml = r#"
3845package:
3846 name: designer-metadata-demo
3847 version: 0.1.0
3848records: []
3849agent_endpoints:
3850 - id: review_claim
3851 title: Review claim
3852 intent: Review a submitted claim before approval.
3853 description: Dispatches a locked claim review action.
3854 inputs:
3855 - name: claim_id
3856 type: string
3857 required: true
3858 - name: approved
3859 type: boolean
3860 required: true
3861 - name: priority
3862 type: string
3863 enum_values:
3864 - low
3865 - high
3866 outputs:
3867 - name: review_id
3868 type: string
3869 side_effects:
3870 - event.ClaimReviewed
3871 risk: medium
3872 approval: required
3873"#;
3874 let built = build_artifacts_from_yaml(yaml).expect("artifacts build");
3875 let document: DesignerNodeTypesDocument =
3876 serde_json::from_str(&built.designer_node_types_json)
3877 .expect("designer node types should parse");
3878 let node_type = document
3879 .node_types
3880 .iter()
3881 .find(|node_type| node_type.id == "sorla.agent-endpoint.review_claim")
3882 .expect("node type should be emitted");
3883
3884 assert_eq!(node_type.label, "Review claim");
3885 assert_eq!(
3886 node_type.metadata.intent,
3887 "Review a submitted claim before approval."
3888 );
3889 assert!(node_type.ui.aliases.contains(&"review claim".to_string()));
3890 let fields = node_type
3891 .ui
3892 .fields
3893 .iter()
3894 .map(|field| {
3895 (
3896 field.name.as_str(),
3897 field.label.as_str(),
3898 field.widget.as_str(),
3899 )
3900 })
3901 .collect::<Vec<_>>();
3902 assert!(fields.contains(&("claim_id", "Claim Id", "text")));
3903 assert!(fields.contains(&("approved", "Approved", "checkbox")));
3904 assert!(fields.contains(&("priority", "Priority", "select")));
3905 assert_eq!(
3906 node_type.input_schema["properties"]["priority"]["enum"],
3907 serde_json::json!(["high", "low"])
3908 );
3909 }
3910
3911 #[test]
3912 fn gtpack_doctor_rejects_malformed_designer_node_type_metadata() {
3913 let temp = tempdir().expect("tempdir");
3914 let out = temp.path().join("landlord.gtpack");
3915 build_sorla_gtpack(&SorlaGtpackOptions {
3916 input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
3917 name: "landlord-tenant-sor".to_string(),
3918 version: "0.1.0".to_string(),
3919 out_path: out.clone(),
3920 })
3921 .expect("pack builds");
3922
3923 rewrite_gtpack(&out, |path, bytes| {
3924 if path == DESIGNER_NODE_TYPES_PATH {
3925 let mut document: DesignerNodeTypesDocument =
3926 serde_json::from_slice(bytes).expect("designer node types parse");
3927 document.node_types[0].metadata.endpoint.contract_hash = "sha256:BAD".to_string();
3928 *bytes = serde_json::to_vec_pretty(&document).expect("document serializes");
3929 }
3930 true
3931 });
3932
3933 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
3934 assert!(err.contains("invalid contract_hash"));
3935 }
3936
3937 #[test]
3938 fn gtpack_doctor_rejects_tampered_action_catalog_metadata() {
3939 let temp = tempdir().expect("tempdir");
3940 let out = temp.path().join("landlord.gtpack");
3941 build_sorla_gtpack(&SorlaGtpackOptions {
3942 input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
3943 name: "landlord-tenant-sor".to_string(),
3944 version: "0.1.0".to_string(),
3945 out_path: out.clone(),
3946 })
3947 .expect("pack builds");
3948
3949 rewrite_gtpack(&out, |path, bytes| {
3950 if path == AGENT_ENDPOINT_ACTION_CATALOG_PATH {
3951 let mut document: AgentEndpointActionCatalogDocument =
3952 serde_json::from_slice(bytes).expect("action catalog parses");
3953 document.actions[0].endpoint_ref.contract_hash = "sha256:BAD".to_string();
3954 *bytes = serde_json::to_vec_pretty(&document).expect("document serializes");
3955 }
3956 true
3957 });
3958
3959 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
3960 assert!(err.contains("endpoint_ref does not match model metadata"));
3961 }
3962
3963 #[test]
3964 fn gtpack_doctor_rejects_metadata_asset_without_lock_update() {
3965 let temp = tempdir().expect("tempdir");
3966 let out = temp.path().join("landlord.gtpack");
3967 build_sorla_gtpack(&SorlaGtpackOptions {
3968 input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
3969 name: "landlord-tenant-sor".to_string(),
3970 version: "0.1.0".to_string(),
3971 out_path: out.clone(),
3972 })
3973 .expect("pack builds");
3974
3975 rewrite_gtpack_preserving_lock(&out, |path, bytes| {
3976 if path == AGENT_ENDPOINT_ACTION_CATALOG_PATH {
3977 bytes.push(b'\n');
3978 }
3979 true
3980 });
3981
3982 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
3983 assert!(err.contains("pack.lock.cbor"));
3984 }
3985
3986 #[test]
3987 fn validation_pack_assets_match_golden_snapshots() {
3988 let temp = tempdir().expect("tempdir");
3989 let minimal_out = temp.path().join("minimal.gtpack");
3990 build_sorla_gtpack(&SorlaGtpackOptions {
3991 input_path: PathBuf::from("tests/golden/tenant_v0_2.sorla.yaml"),
3992 name: "tenancy".to_string(),
3993 version: "0.2.0".to_string(),
3994 out_path: minimal_out.clone(),
3995 })
3996 .expect("minimal pack builds");
3997 assert_gtpack_json_matches_fixture(
3998 &minimal_out,
3999 SORX_VALIDATION_MANIFEST_PATH,
4000 "../../tests/fixtures/validation/minimal/test-manifest.json",
4001 );
4002
4003 let landlord_out = temp.path().join("landlord.gtpack");
4004 build_sorla_gtpack(&SorlaGtpackOptions {
4005 input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
4006 name: "landlord-tenant-sor".to_string(),
4007 version: "0.1.0".to_string(),
4008 out_path: landlord_out.clone(),
4009 })
4010 .expect("landlord pack builds");
4011 assert_gtpack_json_matches_fixture(
4012 &landlord_out,
4013 SORX_VALIDATION_MANIFEST_PATH,
4014 "../../tests/fixtures/validation/landlord-tenant/test-manifest.json",
4015 );
4016 assert_gtpack_json_matches_fixture(
4017 &landlord_out,
4018 SORX_EXPOSURE_POLICY_PATH,
4019 "../../tests/fixtures/validation/landlord-tenant/exposure-policy.json",
4020 );
4021 assert_gtpack_json_matches_fixture(
4022 &landlord_out,
4023 SORX_COMPATIBILITY_PATH,
4024 "../../tests/fixtures/validation/landlord-tenant/compatibility.json",
4025 );
4026 }
4027
4028 #[test]
4029 fn ontology_gtpack_artifacts_are_deterministic_and_discoverable() {
4030 let temp = tempdir().expect("tempdir");
4031 let input = write_ontology_fixture(temp.path());
4032 let first_out = temp.path().join("first.gtpack");
4033 let second_out = temp.path().join("second.gtpack");
4034 let options = |out_path: PathBuf| SorlaGtpackOptions {
4035 input_path: input.clone(),
4036 name: "ontology-demo".to_string(),
4037 version: "0.1.0".to_string(),
4038 out_path,
4039 };
4040
4041 let first = build_sorla_gtpack(&options(first_out.clone())).expect("first pack builds");
4042 let second = build_sorla_gtpack(&options(second_out.clone())).expect("second pack builds");
4043
4044 assert_eq!(
4045 fs::read(&first_out).unwrap(),
4046 fs::read(&second_out).unwrap()
4047 );
4048 assert_eq!(first.ir_hash, second.ir_hash);
4049 for path in [
4050 ONTOLOGY_GRAPH_PATH,
4051 ONTOLOGY_IR_CBOR_PATH,
4052 ONTOLOGY_SCHEMA_PATH,
4053 RETRIEVAL_BINDINGS_PATH,
4054 RETRIEVAL_BINDINGS_IR_CBOR_PATH,
4055 ] {
4056 assert!(first.assets.contains(&path.to_string()), "{path}");
4057 }
4058
4059 let inspection = inspect_sorla_gtpack(&first_out).expect("inspect pack");
4060 let ontology = inspection
4061 .ontology
4062 .as_ref()
4063 .expect("inspect should summarize ontology metadata");
4064 assert_eq!(ontology.schema, ONTOLOGY_EXTENSION_ID);
4065 assert_eq!(ontology.graph_schema, ONTOLOGY_GRAPH_SCHEMA);
4066 assert_eq!(ontology.concept_count, 3);
4067 assert_eq!(ontology.relationship_count, 1);
4068 assert_eq!(ontology.constraint_count, 1);
4069 let retrieval = inspection
4070 .retrieval_bindings
4071 .as_ref()
4072 .expect("inspect should summarize retrieval bindings");
4073 assert_eq!(retrieval.schema, RETRIEVAL_BINDINGS_SCHEMA);
4074 assert_eq!(retrieval.provider_count, 1);
4075 assert_eq!(retrieval.scope_count, 1);
4076 assert_eq!(
4077 inspection.optional_artifacts.get(ONTOLOGY_GRAPH_PATH),
4078 Some(&true)
4079 );
4080
4081 let mut archive =
4082 ZipArchive::new(fs::File::open(&first_out).expect("open pack")).expect("read pack");
4083 let graph: serde_json::Value =
4084 serde_json::from_str(&zip_text(&mut archive, ONTOLOGY_GRAPH_PATH).expect("graph"))
4085 .expect("graph JSON");
4086 assert_eq!(
4087 graph["semantic_aliases"]["concepts"]["Customer"],
4088 serde_json::json!(["client", "customer account"])
4089 );
4090 assert_eq!(
4091 graph["entity_linking"]["strategies"][0]["id"],
4092 "email_match"
4093 );
4094 let retrieval_json: serde_json::Value = serde_json::from_str(
4095 &zip_text(&mut archive, RETRIEVAL_BINDINGS_PATH).expect("retrieval JSON"),
4096 )
4097 .expect("retrieval JSON parses");
4098 assert_eq!(retrieval_json["providers"][0]["id"], "primary_evidence");
4099 let pack_manifest: SorlaPackManifest = ciborium::de::from_reader(Cursor::new(
4100 zip_bytes(&mut archive, "pack.cbor").expect("pack.cbor"),
4101 ))
4102 .expect("pack manifest decodes");
4103 let ontology_extension = pack_manifest
4104 .extension
4105 .get("sorla")
4106 .and_then(|sorla| sorla.get("ontology"))
4107 .expect("ontology extension is declared");
4108 assert_eq!(ontology_extension["schema"], ONTOLOGY_EXTENSION_ID);
4109 assert_eq!(ontology_extension["graph"], ONTOLOGY_GRAPH_PATH);
4110 assert_eq!(ontology_extension["ir"], ONTOLOGY_IR_CBOR_PATH);
4111 assert_eq!(ontology_extension["json_schema"], ONTOLOGY_SCHEMA_PATH);
4112
4113 doctor_sorla_gtpack(&first_out).expect("doctor accepts ontology pack");
4114 }
4115
4116 #[test]
4117 fn gtpack_doctor_rejects_missing_ontology_graph() {
4118 let temp = tempdir().expect("tempdir");
4119 let input = write_ontology_fixture(temp.path());
4120 let out = temp.path().join("ontology.gtpack");
4121 build_sorla_gtpack(&SorlaGtpackOptions {
4122 input_path: input,
4123 name: "ontology-demo".to_string(),
4124 version: "0.1.0".to_string(),
4125 out_path: out.clone(),
4126 })
4127 .expect("pack builds");
4128
4129 rewrite_gtpack(&out, |path, _bytes| path != ONTOLOGY_GRAPH_PATH);
4130
4131 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
4132 assert!(err.contains(ONTOLOGY_GRAPH_PATH));
4133 }
4134
4135 #[test]
4136 fn gtpack_doctor_rejects_ontology_graph_that_diverges_from_ir() {
4137 let temp = tempdir().expect("tempdir");
4138 let input = write_ontology_fixture(temp.path());
4139 let out = temp.path().join("ontology.gtpack");
4140 build_sorla_gtpack(&SorlaGtpackOptions {
4141 input_path: input,
4142 name: "ontology-demo".to_string(),
4143 version: "0.1.0".to_string(),
4144 out_path: out.clone(),
4145 })
4146 .expect("pack builds");
4147
4148 rewrite_gtpack(&out, |path, bytes| {
4149 if path == ONTOLOGY_GRAPH_PATH {
4150 let mut graph: serde_json::Value =
4151 serde_json::from_slice(bytes).expect("graph JSON");
4152 graph["concepts"][0]["id"] =
4153 serde_json::Value::String("UnknownConcept".to_string());
4154 *bytes = serde_json::to_vec_pretty(&graph).expect("graph serializes");
4155 }
4156 true
4157 });
4158
4159 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
4160 assert!(err.contains("concepts do not match"));
4161 }
4162
4163 #[test]
4164 fn gtpack_doctor_rejects_unsafe_ontology_manifest_path() {
4165 let temp = tempdir().expect("tempdir");
4166 let input = write_ontology_fixture(temp.path());
4167 let out = temp.path().join("ontology.gtpack");
4168 build_sorla_gtpack(&SorlaGtpackOptions {
4169 input_path: input,
4170 name: "ontology-demo".to_string(),
4171 version: "0.1.0".to_string(),
4172 out_path: out.clone(),
4173 })
4174 .expect("pack builds");
4175
4176 rewrite_gtpack(&out, |path, bytes| {
4177 if path == "pack.cbor" || path == "manifest.cbor" {
4178 let mut manifest: SorlaPackManifest =
4179 ciborium::de::from_reader(Cursor::new(bytes.clone()))
4180 .expect("manifest decodes");
4181 manifest.extension["sorla"]["ontology"]["graph"] =
4182 serde_json::Value::String("../ontology.graph.json".to_string());
4183 *bytes = canonical_cbor(&manifest);
4184 }
4185 if path == "manifest.json" {
4186 let mut manifest: SorlaPackManifest =
4187 serde_json::from_slice(bytes).expect("manifest JSON decodes");
4188 manifest.extension["sorla"]["ontology"]["graph"] =
4189 serde_json::Value::String("../ontology.graph.json".to_string());
4190 *bytes = serde_json::to_vec_pretty(&manifest).expect("manifest serializes");
4191 }
4192 true
4193 });
4194
4195 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
4196 assert!(err.contains("ontology asset path"));
4197 }
4198
4199 fn assert_gtpack_json_matches_fixture(pack_path: &Path, asset_path: &str, fixture_path: &str) {
4200 let mut archive =
4201 ZipArchive::new(fs::File::open(pack_path).expect("open pack")).expect("read pack");
4202 let actual = zip_text(&mut archive, asset_path).expect("asset should exist");
4203 let expected = fs::read_to_string(fixture_path).expect("fixture should read");
4204 assert_eq!(actual.trim_end(), expected.trim_end(), "{asset_path}");
4205 }
4206
4207 fn write_ontology_fixture(dir: &Path) -> PathBuf {
4208 let path = dir.join("ontology.sorla.yaml");
4209 fs::write(
4210 &path,
4211 r#"
4212package:
4213 name: ontology-demo
4214 version: 0.1.0
4215records:
4216 - name: Customer
4217 fields:
4218 - name: id
4219 type: string
4220 - name: email
4221 type: string
4222 sensitive: true
4223 - name: Contract
4224 fields:
4225 - name: id
4226 type: string
4227 - name: CustomerContract
4228 fields:
4229 - name: customer_id
4230 type: string
4231 references:
4232 record: Customer
4233 field: id
4234 - name: contract_id
4235 type: string
4236 references:
4237 record: Contract
4238 field: id
4239ontology:
4240 schema: greentic.sorla.ontology.v1
4241 concepts:
4242 - id: Party
4243 kind: abstract
4244 - id: Customer
4245 kind: entity
4246 extends: Party
4247 backed_by:
4248 record: Customer
4249 sensitivity:
4250 classification: confidential
4251 pii: true
4252 - id: Contract
4253 kind: entity
4254 backed_by:
4255 record: Contract
4256 relationships:
4257 - id: has_contract
4258 from: Customer
4259 to: Contract
4260 cardinality:
4261 from: one
4262 to: many
4263 backed_by:
4264 record: CustomerContract
4265 from_field: customer_id
4266 to_field: contract_id
4267 constraints:
4268 - id: customer_data_policy
4269 applies_to:
4270 concept: Customer
4271 requires_policy: customer_data_access
4272semantic_aliases:
4273 concepts:
4274 Customer:
4275 - customer account
4276 - client
4277 relationships:
4278 has_contract:
4279 - covered by
4280entity_linking:
4281 strategies:
4282 - id: email_match
4283 applies_to: Customer
4284 match:
4285 source_field: email
4286 target_field: email
4287 confidence: 0.95
4288 sensitivity:
4289 pii: true
4290retrieval_bindings:
4291 schema: greentic.sorla.retrieval-bindings.v1
4292 providers:
4293 - id: primary_evidence
4294 category: evidence
4295 required_capabilities:
4296 - evidence.query
4297 - entity.link
4298 scopes:
4299 - id: customer_evidence
4300 applies_to:
4301 concept: Customer
4302 provider: primary_evidence
4303 filters:
4304 entity_scope:
4305 include_self: true
4306 include_related:
4307 - relationship: has_contract
4308 direction: outgoing
4309 max_depth: 1
4310"#,
4311 )
4312 .expect("write ontology fixture");
4313 path
4314 }
4315
4316 fn rewrite_gtpack(path: &Path, mut f: impl FnMut(&str, &mut Vec<u8>) -> bool) {
4317 let mut archive =
4318 ZipArchive::new(fs::File::open(path).expect("open pack")).expect("read pack");
4319 let mut entries = BTreeMap::new();
4320 for index in 0..archive.len() {
4321 let mut entry = archive.by_index(index).expect("entry");
4322 if entry.name() == "pack.lock.cbor" {
4323 continue;
4324 }
4325 let name = entry.name().to_string();
4326 let mut bytes = Vec::new();
4327 entry.read_to_end(&mut bytes).expect("read entry");
4328 if f(&name, &mut bytes) {
4329 entries.insert(name, bytes);
4330 }
4331 }
4332 drop(archive);
4333 let lock = pack_lock_for_entries(&entries);
4334 entries.insert("pack.lock.cbor".to_string(), canonical_cbor(&lock));
4335 write_zip_entries(path, entries).expect("rewrite pack");
4336 }
4337
4338 fn rewrite_gtpack_preserving_lock(path: &Path, mut f: impl FnMut(&str, &mut Vec<u8>) -> bool) {
4339 let mut archive =
4340 ZipArchive::new(fs::File::open(path).expect("open pack")).expect("read pack");
4341 let mut entries = BTreeMap::new();
4342 for index in 0..archive.len() {
4343 let mut entry = archive.by_index(index).expect("entry");
4344 let name = entry.name().to_string();
4345 let mut bytes = Vec::new();
4346 entry.read_to_end(&mut bytes).expect("read entry");
4347 if f(&name, &mut bytes) {
4348 entries.insert(name, bytes);
4349 }
4350 }
4351 drop(archive);
4352 write_zip_entries(path, entries).expect("rewrite pack");
4353 }
4354
4355 #[test]
4356 fn gtpack_doctor_rejects_shared_state_without_migration_metadata() {
4357 let temp = tempdir().expect("tempdir");
4358 let out = temp.path().join("landlord.gtpack");
4359 build_sorla_gtpack(&SorlaGtpackOptions {
4360 input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
4361 name: "landlord-tenant-sor".to_string(),
4362 version: "0.1.0".to_string(),
4363 out_path: out.clone(),
4364 })
4365 .expect("pack builds");
4366
4367 let mut archive =
4368 ZipArchive::new(fs::File::open(&out).expect("open pack")).expect("read pack");
4369 let mut entries = BTreeMap::new();
4370 for index in 0..archive.len() {
4371 let mut entry = archive.by_index(index).expect("entry");
4372 if entry.name() == "pack.lock.cbor" {
4373 continue;
4374 }
4375 let mut bytes = Vec::new();
4376 entry.read_to_end(&mut bytes).expect("read entry");
4377 if entry.name() == SORX_COMPATIBILITY_PATH {
4378 let mut compatibility: serde_json::Value =
4379 serde_json::from_slice(&bytes).expect("compatibility JSON");
4380 compatibility["state_compatibility"] =
4381 serde_json::Value::String("shared_allowed".to_string());
4382 compatibility["migration_compatibility"] = serde_json::Value::Array(Vec::new());
4383 bytes =
4384 serde_json::to_vec_pretty(&compatibility).expect("compatibility serializes");
4385 }
4386 entries.insert(entry.name().to_string(), bytes);
4387 }
4388 drop(archive);
4389 let lock = pack_lock_for_entries(&entries);
4390 entries.insert("pack.lock.cbor".to_string(), canonical_cbor(&lock));
4391 write_zip_entries(&out, entries).expect("rewrite malformed pack");
4392
4393 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
4394 assert!(err.contains("shared state"));
4395 }
4396
4397 #[test]
4398 fn gtpack_doctor_rejects_missing_validation_fixture_reference() {
4399 let temp = tempdir().expect("tempdir");
4400 let out = temp.path().join("landlord.gtpack");
4401 build_sorla_gtpack(&SorlaGtpackOptions {
4402 input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
4403 name: "landlord-tenant-sor".to_string(),
4404 version: "0.1.0".to_string(),
4405 out_path: out.clone(),
4406 })
4407 .expect("pack builds");
4408
4409 let mut archive =
4410 ZipArchive::new(fs::File::open(&out).expect("open pack")).expect("read pack");
4411 let mut entries = BTreeMap::new();
4412 for index in 0..archive.len() {
4413 let mut entry = archive.by_index(index).expect("entry");
4414 if entry.name() == "pack.lock.cbor" {
4415 continue;
4416 }
4417 let mut bytes = Vec::new();
4418 entry.read_to_end(&mut bytes).expect("read entry");
4419 if entry.name() == SORX_VALIDATION_MANIFEST_PATH {
4420 let mut manifest: serde_json::Value =
4421 serde_json::from_slice(&bytes).expect("validation manifest JSON");
4422 manifest["suites"][0]["tests"][0]["input_ref"] =
4423 serde_json::Value::String("fixtures/missing.json".to_string());
4424 bytes = serde_json::to_vec_pretty(&manifest).expect("manifest serializes");
4425 }
4426 entries.insert(entry.name().to_string(), bytes);
4427 }
4428 drop(archive);
4429 let lock = pack_lock_for_entries(&entries);
4430 entries.insert("pack.lock.cbor".to_string(), canonical_cbor(&lock));
4431 write_zip_entries(&out, entries).expect("rewrite malformed pack");
4432
4433 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
4434 assert!(err.contains("assets/sorx/tests/fixtures/missing.json"));
4435 }
4436
4437 #[test]
4438 fn gtpack_doctor_rejects_public_candidate_exposure_default() {
4439 let temp = tempdir().expect("tempdir");
4440 let out = temp.path().join("landlord.gtpack");
4441 build_sorla_gtpack(&SorlaGtpackOptions {
4442 input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
4443 name: "landlord-tenant-sor".to_string(),
4444 version: "0.1.0".to_string(),
4445 out_path: out.clone(),
4446 })
4447 .expect("pack builds");
4448
4449 let mut archive =
4450 ZipArchive::new(fs::File::open(&out).expect("open pack")).expect("read pack");
4451 let mut entries = BTreeMap::new();
4452 for index in 0..archive.len() {
4453 let mut entry = archive.by_index(index).expect("entry");
4454 if entry.name() == "pack.lock.cbor" {
4455 continue;
4456 }
4457 let mut bytes = Vec::new();
4458 entry.read_to_end(&mut bytes).expect("read entry");
4459 if entry.name() == SORX_EXPOSURE_POLICY_PATH {
4460 let mut policy: serde_json::Value =
4461 serde_json::from_slice(&bytes).expect("exposure policy JSON");
4462 policy["default_visibility"] =
4463 serde_json::Value::String("public_candidate".to_string());
4464 bytes = serde_json::to_vec_pretty(&policy).expect("policy serializes");
4465 }
4466 entries.insert(entry.name().to_string(), bytes);
4467 }
4468 drop(archive);
4469 let lock = pack_lock_for_entries(&entries);
4470 entries.insert("pack.lock.cbor".to_string(), canonical_cbor(&lock));
4471 write_zip_entries(&out, entries).expect("rewrite malformed pack");
4472
4473 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
4474 assert!(err.contains("default_visibility"));
4475 }
4476
4477 #[test]
4478 fn gtpack_doctor_rejects_invalid_validation_schema() {
4479 let temp = tempdir().expect("tempdir");
4480 let out = temp.path().join("landlord.gtpack");
4481 build_sorla_gtpack(&SorlaGtpackOptions {
4482 input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
4483 name: "landlord-tenant-sor".to_string(),
4484 version: "0.1.0".to_string(),
4485 out_path: out.clone(),
4486 })
4487 .expect("pack builds");
4488
4489 let mut archive =
4490 ZipArchive::new(fs::File::open(&out).expect("open pack")).expect("read pack");
4491 let mut entries = BTreeMap::new();
4492 for index in 0..archive.len() {
4493 let mut entry = archive.by_index(index).expect("entry");
4494 if entry.name() == "pack.lock.cbor" {
4495 continue;
4496 }
4497 let mut bytes = Vec::new();
4498 entry.read_to_end(&mut bytes).expect("read entry");
4499 if entry.name() == SORX_VALIDATION_MANIFEST_PATH {
4500 let mut manifest: serde_json::Value =
4501 serde_json::from_slice(&bytes).expect("validation manifest JSON");
4502 manifest["schema"] = serde_json::Value::String("wrong.schema".to_string());
4503 bytes = serde_json::to_vec_pretty(&manifest).expect("manifest serializes");
4504 }
4505 entries.insert(entry.name().to_string(), bytes);
4506 }
4507 drop(archive);
4508 let lock = pack_lock_for_entries(&entries);
4509 entries.insert("pack.lock.cbor".to_string(), canonical_cbor(&lock));
4510 write_zip_entries(&out, entries).expect("rewrite malformed pack");
4511
4512 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
4513 assert!(err.contains(SORX_VALIDATION_SCHEMA));
4514 }
4515
4516 #[test]
4517 fn gtpack_doctor_rejects_missing_required_asset() {
4518 let temp = tempdir().expect("tempdir");
4519 let out = temp.path().join("landlord.gtpack");
4520 build_sorla_gtpack(&SorlaGtpackOptions {
4521 input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
4522 name: "landlord-tenant-sor".to_string(),
4523 version: "0.1.0".to_string(),
4524 out_path: out.clone(),
4525 })
4526 .expect("pack builds");
4527
4528 let mut archive =
4529 ZipArchive::new(fs::File::open(&out).expect("open pack")).expect("read pack");
4530 let mut entries = BTreeMap::new();
4531 for index in 0..archive.len() {
4532 let mut entry = archive.by_index(index).expect("entry");
4533 if entry.name() == "assets/sorla/model.cbor" {
4534 continue;
4535 }
4536 let mut bytes = Vec::new();
4537 entry.read_to_end(&mut bytes).expect("read entry");
4538 entries.insert(entry.name().to_string(), bytes);
4539 }
4540 drop(archive);
4541 write_zip_entries(&out, entries).expect("rewrite malformed pack");
4542
4543 let err = doctor_sorla_gtpack(&out).expect_err("doctor should reject malformed pack");
4544 assert!(err.contains("model.cbor"));
4545 }
4546
4547 #[test]
4548 fn gtpack_build_rejects_missing_input() {
4549 let temp = tempdir().expect("tempdir");
4550 let err = build_sorla_gtpack(&SorlaGtpackOptions {
4551 input_path: temp.path().join("missing.sorla.yaml"),
4552 name: "missing".to_string(),
4553 version: "0.1.0".to_string(),
4554 out_path: temp.path().join("missing.gtpack"),
4555 })
4556 .expect_err("missing input should fail");
4557
4558 assert!(err.contains("failed to read SoRLa input"));
4559 }
4560
4561 #[test]
4562 fn agent_gateway_manifest_handles_empty_packages() {
4563 let parsed = parse_package(
4564 r#"
4565package:
4566 name: empty-agent-package
4567 version: 0.2.0
4568"#,
4569 )
4570 .expect("fixture should parse");
4571 let ir = lower_package(&parsed.package);
4572 let manifest = agent_gateway_handoff_manifest(&ir);
4573
4574 assert_eq!(manifest.schema, AGENT_GATEWAY_HANDOFF_SCHEMA);
4575 assert_eq!(manifest.package.name, "empty-agent-package");
4576 assert_eq!(manifest.package.ir_hash, canonical_hash_hex(&ir));
4577 assert!(manifest.endpoints.is_empty());
4578 assert!(manifest.provider_contract.categories.is_empty());
4579 assert!(manifest.exports.agent_gateway_json);
4580 assert!(!manifest.exports.openapi_overlay);
4581 assert!(!manifest.exports.arazzo);
4582 assert!(!manifest.exports.mcp_tools);
4583 assert!(!manifest.exports.llms_txt);
4584 assert!(manifest.notes[0].contains("handoff metadata"));
4585 assert!(manifest.notes[0].contains("not final runtime gateway behavior"));
4586 }
4587
4588 #[test]
4589 fn executable_contract_exports_relationships_migrations_and_agent_operations() {
4590 let parsed = parse_package(
4591 r#"
4592package:
4593 name: leasing
4594 version: 0.3.0
4595records:
4596 - name: Landlord
4597 fields:
4598 - name: id
4599 type: string
4600 - name: Tenant
4601 fields:
4602 - name: id
4603 type: string
4604 - name: landlord_id
4605 type: string
4606 references:
4607 record: Landlord
4608 field: id
4609 - name: preferred_contact_method
4610 type: string
4611events:
4612 - name: TenantCreated
4613 record: Tenant
4614projections:
4615 - name: TenantCurrentState
4616 record: Tenant
4617 source_event: TenantCreated
4618migrations:
4619 - name: tenant-v2
4620 compatibility: additive
4621 idempotence_key: tenant-v2-fields
4622 backfills:
4623 - record: Tenant
4624 field: preferred_contact_method
4625 default: email
4626 projection_updates:
4627 - TenantCurrentState
4628agent_endpoints:
4629 - id: create_tenant
4630 title: Create tenant
4631 intent: Create a tenant and emit the event contract.
4632 inputs:
4633 - name: full_name
4634 type: string
4635 emits:
4636 event: TenantCreated
4637 stream: "tenant/{tenant_id}"
4638 payload:
4639 full_name: "$input.full_name"
4640"#,
4641 )
4642 .expect("fixture should parse");
4643 let ir = lower_package(&parsed.package);
4644 let contract: serde_json::Value = serde_json::from_str(&executable_contract_json(&ir))
4645 .expect("executable contract should be valid JSON");
4646
4647 assert_eq!(contract["schema"], "greentic.sorla.executable-contract.v1");
4648 assert_eq!(contract["relationships"][0]["record"], "Tenant");
4649 assert_eq!(
4650 contract["relationships"][0]["references"]["record"],
4651 "Landlord"
4652 );
4653 assert_eq!(
4654 contract["migrations"][0]["idempotence_key"],
4655 "tenant-v2-fields"
4656 );
4657 assert_eq!(
4658 contract["migrations"][0]["backfills"][0]["default"],
4659 serde_json::json!("email")
4660 );
4661 assert_eq!(
4662 contract["agent_operations"][0]["emits"]["event"],
4663 "TenantCreated"
4664 );
4665 assert_eq!(
4666 contract["operation_result_contract"]["schema"],
4667 "greentic.sorla.operation-result.v1"
4668 );
4669 assert_eq!(
4670 contract["operation_result_contract"]["fields"]["status"][1],
4671 "validation_error"
4672 );
4673 }
4674
4675 #[test]
4676 fn agent_gateway_manifest_aggregates_visibility_and_provider_requirements() {
4677 let parsed = parse_package(
4678 r#"
4679package:
4680 name: website-lead-capture
4681 version: 0.2.0
4682provider_requirements:
4683 - category: crm
4684 capabilities:
4685 - contacts.read
4686 - category: storage
4687 capabilities:
4688 - event-log
4689actions:
4690 - name: UpsertContact
4691records:
4692 - name: Contact
4693 fields:
4694 - name: id
4695 type: string
4696events:
4697 - name: ContactCaptured
4698 record: Contact
4699agent_endpoints:
4700 - id: create_customer_contact
4701 title: Create customer contact
4702 intent: Capture a customer enquiry.
4703 inputs:
4704 - name: email
4705 type: string
4706 required: true
4707 - name: company_name
4708 type: string
4709 required: true
4710 outputs:
4711 - name: contact_id
4712 type: string
4713 side_effects:
4714 - crm.contact.upsert
4715 risk: medium
4716 approval: optional
4717 provider_requirements:
4718 - category: crm
4719 capabilities:
4720 - contacts.write
4721 - contacts.read
4722 - category: api-gateway
4723 capabilities:
4724 - route.publish
4725 backing:
4726 actions:
4727 - UpsertContact
4728 events:
4729 - ContactCaptured
4730 agent_visibility:
4731 openapi: true
4732 arazzo: false
4733 mcp: true
4734 llms_txt: false
4735 examples:
4736 - name: lead
4737 summary: Capture a lead.
4738"#,
4739 )
4740 .expect("fixture should parse");
4741
4742 let ir = lower_package(&parsed.package);
4743 let first = agent_gateway_handoff_manifest(&ir);
4744 let second = agent_gateway_handoff_manifest(&ir);
4745 assert_eq!(first, second);
4746 assert_eq!(first.package.ir_hash, canonical_hash_hex(&ir));
4747 assert_eq!(first.endpoints.len(), 1);
4748 assert_eq!(first.endpoints[0].id, "create_customer_contact");
4749 assert_eq!(first.endpoints[0].risk, "medium");
4750 assert_eq!(first.endpoints[0].approval, "optional");
4751 assert_eq!(first.endpoints[0].inputs, ["company_name", "email"]);
4752 assert_eq!(first.endpoints[0].outputs, ["contact_id"]);
4753 assert!(first.exports.openapi_overlay);
4754 assert!(!first.exports.arazzo);
4755 assert!(first.exports.mcp_tools);
4756 assert!(!first.exports.llms_txt);
4757
4758 assert_eq!(
4759 first.provider_contract.categories[0].category,
4760 "api-gateway"
4761 );
4762 assert_eq!(
4763 first.provider_contract.categories[0].capabilities,
4764 ["route.publish"]
4765 );
4766 assert_eq!(first.provider_contract.categories[1].category, "crm");
4767 assert_eq!(
4768 first.provider_contract.categories[1].capabilities,
4769 ["contacts.read", "contacts.write"]
4770 );
4771 assert_eq!(first.provider_contract.categories[2].category, "storage");
4772 }
4773
4774 #[test]
4775 fn agent_exports_include_only_enabled_targets_and_are_deterministic() {
4776 let ir = lead_capture_ir();
4777 let first = export_agent_artifacts(&ir);
4778 let second = export_agent_artifacts(&ir);
4779
4780 assert_eq!(first, second);
4781 assert!(
4782 first
4783 .agent_gateway_json
4784 .contains(AGENT_GATEWAY_HANDOFF_SCHEMA)
4785 );
4786 assert!(first.openapi_overlay_yaml.is_some());
4787 assert!(first.arazzo_yaml.is_none());
4788 assert!(first.mcp_tools_json.is_some());
4789 assert!(first.llms_txt.is_some());
4790 }
4791
4792 #[test]
4793 fn mcp_export_includes_required_inputs_and_annotations() {
4794 let exports = export_agent_artifacts(&lead_capture_ir());
4795 let mcp: serde_json::Value = serde_json::from_str(
4796 exports
4797 .mcp_tools_json
4798 .as_deref()
4799 .expect("MCP export should be generated"),
4800 )
4801 .expect("MCP export should be valid JSON");
4802
4803 assert_eq!(mcp["schema"], MCP_TOOLS_HANDOFF_SCHEMA);
4804 assert_eq!(mcp["tools"][0]["name"], "create_customer_contact");
4805 assert_eq!(
4806 mcp["tools"][0]["inputSchema"]["required"][0],
4807 "company_name"
4808 );
4809 assert_eq!(mcp["tools"][0]["inputSchema"]["required"][1], "email");
4810 assert_eq!(
4811 mcp["tools"][0]["annotations"]["side_effects"][0],
4812 "crm.contact.upsert"
4813 );
4814 assert_eq!(mcp["tools"][0]["annotations"]["risk"], "medium");
4815 }
4816
4817 #[test]
4818 fn openapi_and_arazzo_exports_include_agent_handoff_metadata() {
4819 let openapi_exports = export_agent_artifacts(&lead_capture_ir());
4820 let openapi: serde_yaml::Value = serde_yaml::from_str(
4821 openapi_exports
4822 .openapi_overlay_yaml
4823 .as_deref()
4824 .expect("OpenAPI overlay should be generated"),
4825 )
4826 .expect("OpenAPI overlay should be valid YAML");
4827
4828 assert_eq!(openapi["schema"], OPENAPI_AGENT_OVERLAY_SCHEMA);
4829 assert_eq!(
4830 openapi["operations"][0]["x-greentic-agent"]["endpoint_id"],
4831 "create_customer_contact"
4832 );
4833 assert_eq!(
4834 openapi["operations"][0]["x-greentic-agent"]["approval"],
4835 "optional"
4836 );
4837 assert_eq!(
4838 openapi["operations"][0]["x-greentic-agent"]["side_effects"][0],
4839 "crm.contact.upsert"
4840 );
4841
4842 let arazzo_ir = arazzo_visible_ir();
4843 let arazzo_exports = export_agent_artifacts(&arazzo_ir);
4844 let arazzo: serde_yaml::Value = serde_yaml::from_str(
4845 arazzo_exports
4846 .arazzo_yaml
4847 .as_deref()
4848 .expect("Arazzo export should be generated"),
4849 )
4850 .expect("Arazzo export should be valid YAML");
4851 assert_eq!(arazzo["arazzo"], "1.0.1");
4852 assert_eq!(
4853 arazzo["workflows"][0]["workflowId"],
4854 "create_customer_contact"
4855 );
4856 assert_eq!(
4857 arazzo["workflows"][0]["steps"][0]["stepId"],
4858 "request_create_customer_contact"
4859 );
4860 }
4861
4862 #[test]
4863 fn llms_txt_export_includes_safety_metadata() {
4864 let exports = export_agent_artifacts(&lead_capture_ir());
4865 let llms_txt = exports
4866 .llms_txt
4867 .as_deref()
4868 .expect("llms.txt fragment should be generated");
4869
4870 assert!(llms_txt.contains("# website-lead-capture agent endpoints"));
4871 assert!(llms_txt.contains("Intent: Capture a customer enquiry."));
4872 assert!(llms_txt.contains("Risk: medium"));
4873 assert!(llms_txt.contains("Approval: optional"));
4874 assert!(llms_txt.contains("Side effects: crm.contact.upsert"));
4875 assert!(llms_txt.contains("Required inputs: company_name, email"));
4876 assert!(llms_txt.contains("Outputs: contact_id"));
4877 }
4878
4879 fn lead_capture_ir() -> CanonicalIr {
4880 let parsed = parse_package(
4881 r#"
4882package:
4883 name: website-lead-capture
4884 version: 0.2.0
4885records:
4886 - name: Contact
4887 fields:
4888 - name: id
4889 type: string
4890actions:
4891 - name: UpsertContact
4892events:
4893 - name: ContactCaptured
4894 record: Contact
4895agent_endpoints:
4896 - id: create_customer_contact
4897 title: Create customer contact
4898 intent: Capture a customer enquiry.
4899 inputs:
4900 - name: email
4901 type: string
4902 required: true
4903 sensitive: true
4904 - name: company_name
4905 type: string
4906 required: true
4907 - name: company_size
4908 type: string
4909 outputs:
4910 - name: contact_id
4911 type: string
4912 side_effects:
4913 - crm.contact.upsert
4914 risk: medium
4915 approval: optional
4916 backing:
4917 actions:
4918 - UpsertContact
4919 events:
4920 - ContactCaptured
4921 agent_visibility:
4922 openapi: true
4923 arazzo: false
4924 mcp: true
4925 llms_txt: true
4926 examples:
4927 - name: lead
4928 summary: Capture a lead.
4929"#,
4930 )
4931 .expect("fixture should parse");
4932 lower_package(&parsed.package)
4933 }
4934
4935 fn arazzo_visible_ir() -> CanonicalIr {
4936 let parsed = parse_package(
4937 r#"
4938package:
4939 name: website-lead-capture
4940 version: 0.2.0
4941agent_endpoints:
4942 - id: create_customer_contact
4943 title: Create customer contact
4944 intent: Capture a customer enquiry.
4945 inputs:
4946 - name: email
4947 type: string
4948 required: true
4949 outputs:
4950 - name: contact_id
4951 type: string
4952 side_effects:
4953 - crm.contact.upsert
4954 risk: medium
4955 approval: optional
4956 agent_visibility:
4957 openapi: false
4958 arazzo: true
4959 mcp: false
4960 llms_txt: false
4961 examples:
4962 - name: lead
4963 summary: Capture a lead.
4964"#,
4965 )
4966 .expect("fixture should parse");
4967 lower_package(&parsed.package)
4968 }
4969}