Skip to main content

bijux_cli/contracts/
integration_surface_contracts.rs

1use schemars::JsonSchema;
2use semver::{Version, VersionReq};
3use serde::{Deserialize, Serialize};
4
5/// Plugin manifest executable contract for pre-execution validation.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7pub struct ExecutablePluginManifestContractV1 {
8    /// Plugin namespace.
9    pub namespace: String,
10    /// Plugin version.
11    pub version: String,
12    /// Entrypoint descriptor.
13    pub entrypoint: String,
14    /// Declared capabilities.
15    pub capabilities: Vec<String>,
16    /// Trust class identifier.
17    pub trust_class: String,
18    /// Declared command list.
19    pub commands: Vec<String>,
20    /// Host compatibility range.
21    pub compatibility_window: String,
22}
23
24/// Validate executable plugin manifest contract before subprocess execution.
25pub fn validate_executable_plugin_manifest_contract(
26    payload: &ExecutablePluginManifestContractV1,
27) -> Result<(), String> {
28    if payload.namespace.trim().is_empty() {
29        return Err("namespace cannot be empty".to_string());
30    }
31    if payload.entrypoint.trim().is_empty() {
32        return Err("entrypoint cannot be empty".to_string());
33    }
34    if payload.commands.is_empty() {
35        return Err("commands cannot be empty".to_string());
36    }
37    if payload.capabilities.is_empty() {
38        return Err("capabilities cannot be empty".to_string());
39    }
40    if payload.trust_class.trim().is_empty() {
41        return Err("trust_class cannot be empty".to_string());
42    }
43    Version::parse(&payload.version).map_err(|error| format!("invalid version: {error}"))?;
44    VersionReq::parse(&payload.compatibility_window)
45        .map_err(|error| format!("invalid compatibility_window: {error}"))?;
46    Ok(())
47}
48
49/// Hardened plugin subprocess execution policy contract.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct PluginSubprocessExecutionPolicyV1 {
52    /// Normalized argv list used for subprocess launch.
53    pub argv: Vec<String>,
54    /// Timeout in milliseconds.
55    pub timeout_ms: u64,
56    /// Allowed environment variable keys.
57    pub env_allowlist: Vec<String>,
58    /// Working directory policy (`workspace-root`, `plugin-root`, `isolated-temp`).
59    pub working_directory_policy: String,
60    /// Required output envelope schema id.
61    pub output_envelope_schema: String,
62}
63
64/// Validate hardened subprocess policy before plugin execution.
65pub fn validate_plugin_subprocess_execution_policy(
66    payload: &PluginSubprocessExecutionPolicyV1,
67) -> Result<(), String> {
68    if payload.argv.is_empty() {
69        return Err("argv cannot be empty".to_string());
70    }
71    if payload.argv.iter().any(|arg| arg.contains('\n') || arg.contains('\0')) {
72        return Err("argv contains invalid control characters".to_string());
73    }
74    if payload.timeout_ms == 0 {
75        return Err("timeout_ms must be greater than zero".to_string());
76    }
77    if payload.env_allowlist.iter().any(|key| key.trim().is_empty()) {
78        return Err("env_allowlist cannot include blank keys".to_string());
79    }
80    if !matches!(
81        payload.working_directory_policy.as_str(),
82        "workspace-root" | "plugin-root" | "isolated-temp"
83    ) {
84        return Err("working_directory_policy is invalid".to_string());
85    }
86    if payload.output_envelope_schema.trim().is_empty() {
87        return Err("output_envelope_schema cannot be empty".to_string());
88    }
89    Ok(())
90}
91
92/// Generated plugin scaffold conformance entry.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
94pub struct PluginScaffoldConformanceEntryV1 {
95    /// Scaffold language (`rust` or `python`).
96    pub language: String,
97    /// Whether scaffold compiles or imports successfully.
98    pub build_ok: bool,
99    /// Whether scaffold route is discovered by root CLI.
100    pub discovered_by_root_cli: bool,
101    /// Whether scaffold route executes with valid envelope.
102    pub executable_with_valid_envelope: bool,
103}
104
105/// Plugin scaffold conformance report for generated templates.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
107pub struct PluginScaffoldConformanceReportV1 {
108    /// Per-language conformance entries.
109    pub entries: Vec<PluginScaffoldConformanceEntryV1>,
110    /// Overall pass marker.
111    pub fully_conformant: bool,
112}
113
114/// Build plugin scaffold conformance report from generated scaffold checks.
115pub fn build_plugin_scaffold_conformance_report(
116    entries: Vec<PluginScaffoldConformanceEntryV1>,
117) -> Result<PluginScaffoldConformanceReportV1, String> {
118    if entries.is_empty() {
119        return Err("entries cannot be empty".to_string());
120    }
121    let mut ordered = entries;
122    ordered.sort_by(|left, right| left.language.cmp(&right.language));
123    let fully_conformant = ordered.iter().all(|entry| {
124        entry.build_ok && entry.discovered_by_root_cli && entry.executable_with_valid_envelope
125    });
126    Ok(PluginScaffoldConformanceReportV1 { entries: ordered, fully_conformant })
127}
128
129/// Official app descriptor compatibility input contract.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
131pub struct OfficialAppDescriptorCompatibilityInputV1 {
132    /// Host runtime version.
133    pub host_version: String,
134    /// App descriptor version.
135    pub app_version: String,
136    /// Host compatibility requirement declared by app.
137    pub host_compatibility_window: String,
138    /// Lifecycle state (`active`, `deprecated`, `disabled`).
139    pub lifecycle_state: String,
140    /// Declared command surfaces.
141    pub command_surfaces: Vec<String>,
142}
143
144/// Official app descriptor compatibility evaluation output.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
146pub struct OfficialAppDescriptorCompatibilityReportV1 {
147    /// Compatibility marker.
148    pub compatible: bool,
149    /// Refusal reason or migration hint.
150    pub message: String,
151}
152
153/// Evaluate official app descriptor compatibility against host runtime version.
154pub fn evaluate_official_app_descriptor_compatibility(
155    payload: &OfficialAppDescriptorCompatibilityInputV1,
156) -> Result<OfficialAppDescriptorCompatibilityReportV1, String> {
157    let host = Version::parse(&payload.host_version)
158        .map_err(|error| format!("invalid host_version: {error}"))?;
159    Version::parse(&payload.app_version)
160        .map_err(|error| format!("invalid app_version: {error}"))?;
161    let requirement = VersionReq::parse(&payload.host_compatibility_window)
162        .map_err(|error| format!("invalid host_compatibility_window: {error}"))?;
163    if payload.command_surfaces.is_empty() {
164        return Err("command_surfaces cannot be empty".to_string());
165    }
166    if payload.lifecycle_state == "disabled" {
167        return Ok(OfficialAppDescriptorCompatibilityReportV1 {
168            compatible: false,
169            message: "app is disabled; use migration route".to_string(),
170        });
171    }
172    if requirement.matches(&host) {
173        Ok(OfficialAppDescriptorCompatibilityReportV1 {
174            compatible: true,
175            message: "descriptor compatible with host runtime".to_string(),
176        })
177    } else {
178        Ok(OfficialAppDescriptorCompatibilityReportV1 {
179            compatible: false,
180            message: "host version outside compatibility window; migrate app descriptor"
181                .to_string(),
182        })
183    }
184}
185
186/// Legacy shim support policy decision.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188pub struct LegacyShimPolicyDecisionV1 {
189    /// Invoked legacy shim command (e.g. `bijux-dag`).
190    pub shim_command: String,
191    /// Canonical command replacement (e.g. `bijux dag`).
192    pub canonical_command: String,
193    /// Decision (`supported`, `warned`, `refused`).
194    pub decision: String,
195    /// Human actionable message.
196    pub message: String,
197}
198
199/// Evaluate legacy shim policy and provide canonical route mapping.
200pub fn evaluate_legacy_shim_policy(
201    shim_command: &str,
202    canonical_command: &str,
203    shim_mode: &str,
204) -> Result<LegacyShimPolicyDecisionV1, String> {
205    if shim_command.trim().is_empty() || canonical_command.trim().is_empty() {
206        return Err("shim_command and canonical_command cannot be empty".to_string());
207    }
208    let (decision, message) = match shim_mode {
209        "supported" => {
210            ("supported", "legacy shim is temporarily supported; prefer canonical command")
211        }
212        "warned" => ("warned", "legacy shim is deprecated; migrate to canonical command"),
213        "refused" => ("refused", "legacy shim refused; use canonical command"),
214        _ => return Err("shim_mode must be supported, warned, or refused".to_string()),
215    };
216    Ok(LegacyShimPolicyDecisionV1 {
217        shim_command: shim_command.to_string(),
218        canonical_command: canonical_command.to_string(),
219        decision: decision.to_string(),
220        message: message.to_string(),
221    })
222}
223
224/// Route conflict contender metadata.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
226pub struct RouteConflictContenderV1 {
227    /// Contender route source (`built-in`, `official-app`, `plugin`, `alias`, `shim`).
228    pub source: String,
229    /// Resolved command target key.
230    pub target: String,
231    /// Priority where larger values win.
232    pub priority: i32,
233}
234
235/// Deterministic route conflict resolution output.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
237pub struct RouteConflictResolutionV1 {
238    /// Canonical route key.
239    pub route_key: String,
240    /// Winning contender if resolution succeeded.
241    pub winner: Option<RouteConflictContenderV1>,
242    /// Refusal reason when contenders tie irreconcilably.
243    pub refusal_reason: Option<String>,
244}
245
246/// Resolve route conflicts deterministically by priority then stable source ordering.
247pub fn resolve_route_conflict_deterministically(
248    route_key: &str,
249    contenders: Vec<RouteConflictContenderV1>,
250) -> Result<RouteConflictResolutionV1, String> {
251    if route_key.trim().is_empty() {
252        return Err("route_key cannot be empty".to_string());
253    }
254    if contenders.is_empty() {
255        return Ok(RouteConflictResolutionV1 {
256            route_key: route_key.to_string(),
257            winner: None,
258            refusal_reason: Some("no contenders registered".to_string()),
259        });
260    }
261    let mut ordered = contenders;
262    ordered.sort_by(|left, right| {
263        right
264            .priority
265            .cmp(&left.priority)
266            .then_with(|| left.source.cmp(&right.source))
267            .then_with(|| left.target.cmp(&right.target))
268    });
269    let winner = ordered.first().cloned().expect("contenders not empty");
270    let same_rank: Vec<&RouteConflictContenderV1> = ordered
271        .iter()
272        .filter(|candidate| {
273            candidate.priority == winner.priority && candidate.target != winner.target
274        })
275        .collect();
276    if same_rank.is_empty() {
277        Ok(RouteConflictResolutionV1 {
278            route_key: route_key.to_string(),
279            winner: Some(winner),
280            refusal_reason: None,
281        })
282    } else {
283        Ok(RouteConflictResolutionV1 {
284            route_key: route_key.to_string(),
285            winner: None,
286            refusal_reason: Some("priority tie with conflicting targets".to_string()),
287        })
288    }
289}
290
291/// Provenance record for dispatched app route handling.
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
293pub struct AppRouteProvenanceRecordV1 {
294    /// Command route path.
295    pub route_path: String,
296    /// App descriptor hash.
297    pub descriptor_hash: String,
298    /// Handler binary or module identity.
299    pub handler_identity: String,
300    /// Output schema identifier.
301    pub output_schema: String,
302}
303
304/// Build app route provenance record for support and evidence bundles.
305pub fn build_app_route_provenance_record(
306    route_path: &str,
307    descriptor_hash: &str,
308    handler_identity: &str,
309    output_schema: &str,
310) -> Result<AppRouteProvenanceRecordV1, String> {
311    for (field, value) in [
312        ("route_path", route_path),
313        ("descriptor_hash", descriptor_hash),
314        ("handler_identity", handler_identity),
315        ("output_schema", output_schema),
316    ] {
317        if value.trim().is_empty() {
318            return Err(format!("{field} cannot be empty"));
319        }
320    }
321    Ok(AppRouteProvenanceRecordV1 {
322        route_path: route_path.to_string(),
323        descriptor_hash: descriptor_hash.to_string(),
324        handler_identity: handler_identity.to_string(),
325        output_schema: output_schema.to_string(),
326    })
327}
328
329/// SDK example conformance entry for one language implementation.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
331pub struct SdkExampleConformanceEntryV1 {
332    /// Example language (`rust` or `python`).
333    pub language: String,
334    /// Whether example exposes inspectable commands.
335    pub exposes_inspectable_commands: bool,
336    /// Whether example includes config handling.
337    pub supports_config_contract: bool,
338    /// Whether example includes explicit error pathway.
339    pub supports_error_contract: bool,
340    /// Whether machine output follows envelope contract.
341    pub emits_machine_output_envelope: bool,
342}
343
344/// SDK example conformance report for mounted example apps.
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
346pub struct SdkExampleConformanceReportV1 {
347    /// Per-language entries.
348    pub entries: Vec<SdkExampleConformanceEntryV1>,
349    /// Whether all required conformance checks pass.
350    pub fully_conformant: bool,
351}
352
353/// Build SDK example conformance report.
354pub fn build_sdk_example_conformance_report(
355    entries: Vec<SdkExampleConformanceEntryV1>,
356) -> Result<SdkExampleConformanceReportV1, String> {
357    if entries.is_empty() {
358        return Err("entries cannot be empty".to_string());
359    }
360    let mut ordered = entries;
361    ordered.sort_by(|left, right| left.language.cmp(&right.language));
362    let fully_conformant = ordered.iter().all(|entry| {
363        entry.exposes_inspectable_commands
364            && entry.supports_config_contract
365            && entry.supports_error_contract
366            && entry.emits_machine_output_envelope
367    });
368    Ok(SdkExampleConformanceReportV1 { entries: ordered, fully_conformant })
369}
370
371/// Plugin trust-class enforcement decision.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
373pub struct PluginTrustEnforcementDecisionV1 {
374    /// Trust class (`official`, `local`, `experimental`, `disabled`).
375    pub trust_class: String,
376    /// Command classification (`read-only`, `destructive`).
377    pub command_risk: String,
378    /// Whether execution is allowed.
379    pub allowed: bool,
380    /// Decision rationale.
381    pub rationale: String,
382}
383
384/// Enforce plugin trust classes for command execution behavior.
385pub fn enforce_plugin_trust_class_behavior(
386    trust_class: &str,
387    command_risk: &str,
388    experimental_destructive_enabled: bool,
389) -> Result<PluginTrustEnforcementDecisionV1, String> {
390    if trust_class.trim().is_empty() || command_risk.trim().is_empty() {
391        return Err("trust_class and command_risk cannot be empty".to_string());
392    }
393    let decision = match (trust_class, command_risk) {
394        ("disabled", _) => (false, "plugin trust class is disabled"),
395        ("experimental", "destructive") if !experimental_destructive_enabled => {
396            (false, "experimental destructive command requires explicit enable flag")
397        }
398        ("experimental", "destructive") => (true, "experimental destructive override enabled"),
399        (_, _) => (true, "trust policy allows command"),
400    };
401    Ok(PluginTrustEnforcementDecisionV1 {
402        trust_class: trust_class.to_string(),
403        command_risk: command_risk.to_string(),
404        allowed: decision.0,
405        rationale: decision.1.to_string(),
406    })
407}
408
409/// Side-effect-free app capability discovery report.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
411pub struct AppCapabilityDiscoveryReportV1 {
412    /// App namespace.
413    pub app_namespace: String,
414    /// Command groups exposed by descriptor metadata.
415    pub command_groups: Vec<String>,
416    /// Feature flags declared by app descriptor.
417    pub feature_flags: Vec<String>,
418    /// Required config keys.
419    pub required_config_keys: Vec<String>,
420    /// Output schema versions.
421    pub schema_versions: Vec<String>,
422    /// Optional runtime prerequisites missing at discovery time.
423    pub missing_prerequisites: Vec<String>,
424}
425
426/// Build side-effect-free app capability discovery report.
427pub fn build_app_capability_discovery_report(
428    app_namespace: &str,
429    command_groups: Vec<String>,
430    feature_flags: Vec<String>,
431    required_config_keys: Vec<String>,
432    schema_versions: Vec<String>,
433    missing_prerequisites: Vec<String>,
434) -> Result<AppCapabilityDiscoveryReportV1, String> {
435    if app_namespace.trim().is_empty() {
436        return Err("app_namespace cannot be empty".to_string());
437    }
438    if command_groups.is_empty() {
439        return Err("command_groups cannot be empty".to_string());
440    }
441    if schema_versions.is_empty() {
442        return Err("schema_versions cannot be empty".to_string());
443    }
444    Ok(AppCapabilityDiscoveryReportV1 {
445        app_namespace: app_namespace.to_string(),
446        command_groups,
447        feature_flags,
448        required_config_keys,
449        schema_versions,
450        missing_prerequisites,
451    })
452}
453
454#[cfg(test)]
455mod tests {
456    use super::{
457        build_app_capability_discovery_report, build_app_route_provenance_record,
458        build_plugin_scaffold_conformance_report, build_sdk_example_conformance_report,
459        enforce_plugin_trust_class_behavior, evaluate_legacy_shim_policy,
460        evaluate_official_app_descriptor_compatibility, resolve_route_conflict_deterministically,
461        validate_executable_plugin_manifest_contract, validate_plugin_subprocess_execution_policy,
462        ExecutablePluginManifestContractV1, LegacyShimPolicyDecisionV1,
463        OfficialAppDescriptorCompatibilityInputV1, PluginScaffoldConformanceEntryV1,
464        PluginSubprocessExecutionPolicyV1, PluginTrustEnforcementDecisionV1,
465        RouteConflictContenderV1, SdkExampleConformanceEntryV1,
466    };
467
468    #[test]
469    fn plugin_manifest_contract_refuses_invalid_compatibility_window() {
470        let manifest = ExecutablePluginManifestContractV1 {
471            namespace: "community-tools".to_string(),
472            version: "1.2.0".to_string(),
473            entrypoint: "plugin:main".to_string(),
474            capabilities: vec!["inspect".to_string(), "validate".to_string()],
475            trust_class: "local".to_string(),
476            commands: vec!["community lint".to_string()],
477            compatibility_window: "not-semver".to_string(),
478        };
479        assert!(validate_executable_plugin_manifest_contract(&manifest).is_err());
480    }
481
482    #[test]
483    fn plugin_subprocess_policy_refuses_invalid_working_directory_policy() {
484        let policy = PluginSubprocessExecutionPolicyV1 {
485            argv: vec!["plugin-bin".to_string(), "run".to_string()],
486            timeout_ms: 30_000,
487            env_allowlist: vec!["BIJUX_CONFIG_ROOT".to_string()],
488            working_directory_policy: "home-directory".to_string(),
489            output_envelope_schema: "command-envelope-v1".to_string(),
490        };
491        assert!(validate_plugin_subprocess_execution_policy(&policy).is_err());
492    }
493
494    #[test]
495    fn plugin_scaffold_conformance_requires_discovery_and_envelope_validity() {
496        let report = build_plugin_scaffold_conformance_report(vec![
497            PluginScaffoldConformanceEntryV1 {
498                language: "rust".to_string(),
499                build_ok: true,
500                discovered_by_root_cli: true,
501                executable_with_valid_envelope: true,
502            },
503            PluginScaffoldConformanceEntryV1 {
504                language: "python".to_string(),
505                build_ok: true,
506                discovered_by_root_cli: false,
507                executable_with_valid_envelope: true,
508            },
509        ])
510        .expect("conformance report should build");
511        assert!(!report.fully_conformant);
512    }
513
514    #[test]
515    fn descriptor_compatibility_reports_version_window_mismatch() {
516        let report = evaluate_official_app_descriptor_compatibility(
517            &OfficialAppDescriptorCompatibilityInputV1 {
518                host_version: "0.4.0".to_string(),
519                app_version: "1.4.0".to_string(),
520                host_compatibility_window: ">=0.5,<0.6".to_string(),
521                lifecycle_state: "active".to_string(),
522                command_surfaces: vec!["dag run".to_string()],
523            },
524        )
525        .expect("compatibility report should build");
526        assert!(!report.compatible);
527    }
528
529    #[test]
530    fn legacy_shim_policy_warns_with_canonical_route() {
531        let decision: LegacyShimPolicyDecisionV1 =
532            evaluate_legacy_shim_policy("bijux-dag", "bijux dag", "warned")
533                .expect("shim decision should build");
534        assert_eq!(decision.decision, "warned");
535        assert_eq!(decision.canonical_command, "bijux dag");
536    }
537
538    #[test]
539    fn route_conflict_resolution_is_deterministic_by_priority() {
540        let resolution = resolve_route_conflict_deterministically(
541            "dag run",
542            vec![
543                RouteConflictContenderV1 {
544                    source: "plugin".to_string(),
545                    target: "plugin.dag.run".to_string(),
546                    priority: 10,
547                },
548                RouteConflictContenderV1 {
549                    source: "official-app".to_string(),
550                    target: "official.dag.run".to_string(),
551                    priority: 100,
552                },
553            ],
554        )
555        .expect("conflict resolution should succeed");
556        assert_eq!(resolution.winner.expect("winner").target, "official.dag.run");
557        assert!(resolution.refusal_reason.is_none());
558    }
559
560    #[test]
561    fn route_provenance_record_captures_handler_and_descriptor_hash() {
562        let record = build_app_route_provenance_record(
563            "dag run",
564            "sha256:abc123",
565            "bijux-dag-cli::dag::run",
566            "dag-run-envelope-v1",
567        )
568        .expect("provenance record should build");
569        assert_eq!(record.descriptor_hash, "sha256:abc123");
570        assert_eq!(record.handler_identity, "bijux-dag-cli::dag::run");
571    }
572
573    #[test]
574    fn sdk_example_conformance_requires_config_and_error_contracts() {
575        let report = build_sdk_example_conformance_report(vec![
576            SdkExampleConformanceEntryV1 {
577                language: "rust".to_string(),
578                exposes_inspectable_commands: true,
579                supports_config_contract: true,
580                supports_error_contract: true,
581                emits_machine_output_envelope: true,
582            },
583            SdkExampleConformanceEntryV1 {
584                language: "python".to_string(),
585                exposes_inspectable_commands: true,
586                supports_config_contract: false,
587                supports_error_contract: true,
588                emits_machine_output_envelope: true,
589            },
590        ])
591        .expect("sdk conformance should build");
592        assert!(!report.fully_conformant);
593    }
594
595    #[test]
596    fn experimental_destructive_plugin_command_is_blocked_without_override() {
597        let decision: PluginTrustEnforcementDecisionV1 =
598            enforce_plugin_trust_class_behavior("experimental", "destructive", false)
599                .expect("trust decision should build");
600        assert!(!decision.allowed);
601        assert!(decision.rationale.contains("requires explicit enable flag"));
602    }
603
604    #[test]
605    fn app_capability_discovery_reports_missing_optional_prerequisites() {
606        let report = build_app_capability_discovery_report(
607            "dag",
608            vec!["run".to_string(), "plan".to_string()],
609            vec!["cache".to_string()],
610            vec!["dag.run_root".to_string()],
611            vec!["dag-run-envelope-v1".to_string()],
612            vec!["apptainer-not-installed".to_string()],
613        )
614        .expect("capability report should build");
615        assert_eq!(report.app_namespace, "dag");
616        assert_eq!(report.missing_prerequisites, vec!["apptainer-not-installed"]);
617    }
618}