a3s-use-core 0.2.4

Shared typed contracts for A3S Use domains
Documentation
use serde::{Deserialize, Serialize};

use crate::UseResult;

use super::plan::{
    PlanAuthority, PlanEnforcementProfile, PlanQualifiedSurfaceRef, PlanScope,
    PlannedOperationImpact, PlannedPackageTransition, PlannedProviderEvidence,
    PlannedStateEvidence, PlannedWorkspaceImpact, PluginOperationAction, PluginOperationPlan,
};
use super::plan_validation::{planned_okf_changes, planned_secret_changes};
use super::{
    parse_contract, plan::plan_error, PLUGIN_OPERATION_PLAN_DRAFT_SCHEMA_V3,
    PLUGIN_OPERATION_PLAN_SCHEMA_V4,
};

/// Planner-owned operation evidence before the host assigns identity, scope,
/// principal, lifetime, policy, or confirmation requirements.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginOperationPlanDraft {
    pub schema: String,
    pub action: PluginOperationAction,
    pub package_id: String,
    pub component_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub package_lock_digest: Option<String>,
    pub packages: Vec<PlannedPackageTransition>,
    pub providers: Vec<PlannedProviderEvidence>,
    pub workspace_impacts: Vec<PlannedWorkspaceImpact>,
    pub impact: PlannedOperationImpact,
    pub state: PlannedStateEvidence,
}

/// Host-owned fields that turn untrusted planner evidence into an immutable
/// operation plan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginOperationPlanBinding {
    pub operation_id: String,
    pub created_at_ms: u64,
    pub expires_at_ms: u64,
    pub scope: PlanScope,
    pub authority: PlanAuthority,
}

impl PluginOperationPlanDraft {
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        action: PluginOperationAction,
        package_id: impl Into<String>,
        component_id: impl Into<String>,
        packages: Vec<PlannedPackageTransition>,
        providers: Vec<PlannedProviderEvidence>,
        workspace_impacts: Vec<PlannedWorkspaceImpact>,
        impact: PlannedOperationImpact,
        state: PlannedStateEvidence,
    ) -> UseResult<Self> {
        let mut impact = impact;
        impact.okf_changes = planned_okf_changes(action, &packages)?;
        let draft = Self {
            schema: PLUGIN_OPERATION_PLAN_DRAFT_SCHEMA_V3.to_string(),
            action,
            package_id: package_id.into(),
            component_id: component_id.into(),
            package_lock_digest: None,
            packages,
            providers,
            workspace_impacts,
            impact,
            state,
        };
        draft.validate()?;
        Ok(draft)
    }

    /// Construct package and impact evidence before a trusted host performs
    /// executable-provider preflight.
    ///
    /// An unbound draft deliberately carries no provider evidence. For
    /// install, upgrade, and enable, the host must resolve every Tool and MCP
    /// provider, replace `providers`, call [`Self::validate`], and only then
    /// call [`Self::bind`]. Uninstall and disable select no new providers, so
    /// an empty provider set is already final for those actions.
    #[allow(clippy::too_many_arguments)]
    pub fn new_unbound(
        action: PluginOperationAction,
        package_id: impl Into<String>,
        component_id: impl Into<String>,
        packages: Vec<PlannedPackageTransition>,
        workspace_impacts: Vec<PlannedWorkspaceImpact>,
        impact: PlannedOperationImpact,
        state: PlannedStateEvidence,
    ) -> UseResult<Self> {
        let mut impact = impact;
        impact.okf_changes = planned_okf_changes(action, &packages)?;
        let draft = Self {
            schema: PLUGIN_OPERATION_PLAN_DRAFT_SCHEMA_V3.to_string(),
            action,
            package_id: package_id.into(),
            component_id: component_id.into(),
            package_lock_digest: None,
            packages,
            providers: Vec::new(),
            workspace_impacts,
            impact,
            state,
        };
        draft.validate_unbound()?;
        Ok(draft)
    }

    pub fn from_json(input: &[u8]) -> UseResult<Self> {
        parse_contract(
            input,
            "plugin operation plan draft",
            super::plan::PLAN_ERROR,
            Self::validate,
        )
    }

    pub fn from_unbound_json(input: &[u8]) -> UseResult<Self> {
        parse_contract(
            input,
            "unbound plugin operation plan draft",
            super::plan::PLAN_ERROR,
            Self::validate_unbound,
        )
    }

    /// Validate planner-owned evidence without granting it a real host
    /// identity or authority.
    pub fn validate(&self) -> UseResult<()> {
        if self.schema != PLUGIN_OPERATION_PLAN_DRAFT_SCHEMA_V3 {
            return Err(plan_error(
                "The plugin operation plan draft schema is unsupported.",
            ));
        }
        self.clone().bind_unchecked(validation_binding()).map(drop)
    }

    /// Validate package, permission, delta, impact, and durable-state evidence
    /// while requiring provider selection to remain explicitly pending.
    ///
    /// Synthetic providers exist only in the validation clone for actions
    /// that select providers. They cannot be serialized from this method or
    /// accepted by [`Self::bind`]. Retiring actions retain the required empty
    /// provider set.
    pub fn validate_unbound(&self) -> UseResult<()> {
        if self.schema != PLUGIN_OPERATION_PLAN_DRAFT_SCHEMA_V3 || !self.providers.is_empty() {
            return Err(plan_error(
                "An unbound plugin operation draft must use the current schema and contain no provider evidence.",
            ));
        }
        let mut validation = self.clone();
        validation.providers =
            unbound_validation_providers(validation.action, &validation.packages);
        validation.bind_unchecked(validation_binding()).map(drop)
    }

    pub fn bind(self, binding: PluginOperationPlanBinding) -> UseResult<PluginOperationPlan> {
        if self.schema != PLUGIN_OPERATION_PLAN_DRAFT_SCHEMA_V3 {
            return Err(plan_error(
                "The plugin operation plan draft schema is unsupported.",
            ));
        }
        self.bind_unchecked(binding)
    }

    fn bind_unchecked(self, binding: PluginOperationPlanBinding) -> UseResult<PluginOperationPlan> {
        let secret_changes = planned_secret_changes(self.action, &self.packages);
        let plan = PluginOperationPlan {
            schema: PLUGIN_OPERATION_PLAN_SCHEMA_V4.to_string(),
            operation_id: binding.operation_id,
            created_at_ms: binding.created_at_ms,
            expires_at_ms: binding.expires_at_ms,
            action: self.action,
            package_id: self.package_id,
            component_id: self.component_id,
            scope: binding.scope,
            package_lock_digest: self.package_lock_digest,
            prior_package_lock_digest: None,
            packages: self.packages,
            secret_changes,
            providers: self.providers,
            workspace_impacts: self.workspace_impacts,
            impact: self.impact,
            authority: binding.authority,
            state: self.state,
        };
        plan.validate()?;
        Ok(plan)
    }
}

fn unbound_validation_providers(
    action: PluginOperationAction,
    packages: &[PlannedPackageTransition],
) -> Vec<PlannedProviderEvidence> {
    if matches!(
        action,
        PluginOperationAction::Uninstall | PluginOperationAction::Disable
    ) {
        return Vec::new();
    }
    let mut providers = packages
        .iter()
        .filter_map(|package| {
            package
                .after
                .as_ref()
                .map(|state| (package.package_id.as_str(), state))
        })
        .flat_map(|(package_id, state)| {
            state.release.surfaces.iter().filter_map(move |surface| {
                if !matches!(
                    surface.kind,
                    super::PluginSurfaceKind::Tool | super::PluginSurfaceKind::Mcp
                ) {
                    return None;
                }
                let reference = surface.reference();
                let permission = state
                    .permissions
                    .surfaces
                    .iter()
                    .find(|permission| permission.surface == reference)?;
                Some(PlannedProviderEvidence {
                    surface: PlanQualifiedSurfaceRef {
                        package_id: package_id.to_owned(),
                        surface: reference,
                    },
                    provider_id: "a3s-use-unbound-provider".to_owned(),
                    provider_build_id: "host-preflight-pending".to_owned(),
                    capability_digest:
                        "sha256:0000000000000000000000000000000000000000000000000000000000000000"
                            .to_owned(),
                    semantics_profile_digest:
                        "sha256:0000000000000000000000000000000000000000000000000000000000000000"
                            .to_owned(),
                    enforcement: if permission.native_execution {
                        PlanEnforcementProfile::Sandbox
                    } else {
                        PlanEnforcementProfile::Container
                    },
                })
            })
        })
        .collect::<Vec<_>>();
    providers.sort_by(|left, right| left.surface.cmp(&right.surface));
    providers
}

fn validation_binding() -> PluginOperationPlanBinding {
    PluginOperationPlanBinding {
        operation_id: "draft:validation".to_string(),
        created_at_ms: 1,
        expires_at_ms: 2,
        scope: PlanScope {
            kind: super::PlanScopeKind::User,
            id: "current".to_string(),
        },
        authority: PlanAuthority {
            actor: super::PlanActor::User,
            decision: super::PlanPolicyDecision::Ask,
            policy_digest:
                "sha256:0000000000000000000000000000000000000000000000000000000000000000"
                    .to_string(),
            confirmation_required: true,
        },
    }
}