Skip to main content

greentic_sorla_pack/
lib.rs

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}