use serde::{Deserialize, Serialize};
use crate::setup_core::digest;
use crate::provider_v3::error::{Error, Result};
use crate::provider_v3::platform;
use crate::provider_v3::reason::WireReason;
use crate::provider_v3::vocabulary::TargetScope;
use crate::provider_v3::vocabulary::{Operation, PLAN_DOMAIN, PLAN_FORMAT, PROTOCOL_VERSION};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BundleBinding {
pub bundle_format: String,
pub bundle_digest: String,
pub artifact_digest: String,
pub bundle_size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SoftwareArtifact {
pub platform: String,
pub url: String,
pub sha256: String,
pub byte_length: u64,
pub entry_point: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndState {
pub path: String,
pub end_state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub member: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub byte_length: Option<u64>,
}
impl EndState {
pub const REQUEST_FIELD: &'static str = "end_state";
pub const REMOVED: &'static str = "removed";
pub const FINAL_BYTES: &'static str = "final_bytes";
#[must_use]
pub fn removed(path: &str) -> Self {
Self {
path: path.to_owned(),
end_state: Self::REMOVED.to_owned(),
member: None,
sha256: None,
byte_length: None,
}
}
#[must_use]
pub fn final_bytes(path: &str, member: &str, sha256: &str, byte_length: u64) -> Self {
Self {
path: path.to_owned(),
end_state: Self::FINAL_BYTES.to_owned(),
member: Some(member.to_owned()),
sha256: Some(sha256.to_owned()),
byte_length: Some(byte_length),
}
}
#[must_use]
pub fn survives(&self) -> bool {
self.end_state == Self::FINAL_BYTES
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeCapture {
#[serde(default)]
pub base_root: crate::setup_core::native_snapshot::NativeBase,
pub roots: Vec<String>,
pub excluded: Vec<String>,
pub current_digest: String,
pub restore_digest: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlanArtifact {
pub format: String,
pub protocol_version: u32,
pub provider_id: String,
pub provider_version: String,
pub provider_build_digest: String,
pub provider_release_digest: String,
pub operation_id: String,
pub operation: String,
pub canonical_target: String,
pub expected_target_digest: String,
pub projection_profile_digest: String,
pub bundle: Option<BundleBinding>,
pub backup_ref: Option<String>,
pub restore_target_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_capture: Option<NativeCapture>,
pub permission_profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_scope: Option<String>,
pub platform: serde_json::Value,
pub expires_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub software_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub software_version: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub software_artifacts: Vec<SoftwareArtifact>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub end_state: Vec<EndState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instruction_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instruction_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instruction_observed_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instruction_observed_present: Option<bool>,
pub effects: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PlanInputs<'a> {
pub provider_id: &'a str,
pub provider_version: &'a str,
pub provider_build_digest: &'a str,
pub provider_release_digest: &'a str,
pub operation_id: &'a str,
pub operation: Operation,
pub canonical_target: &'a str,
pub expected_target_digest: &'a str,
pub projection_profile_digest: &'a str,
pub bundle: Option<BundleBinding>,
pub backup_ref: Option<String>,
pub restore_target_digest: Option<String>,
pub native_capture: Option<NativeCapture>,
pub permission_profile: Option<String>,
pub target_scope: Option<TargetScope>,
pub expires_at: &'a str,
pub software_artifacts: Vec<SoftwareArtifact>,
pub software_prefix: Option<&'a str>,
pub software_version: Option<&'a str>,
pub end_state: Vec<EndState>,
pub instruction_path: Option<String>,
pub instruction_text: Option<String>,
pub instruction_observed_digest: Option<String>,
pub instruction_observed_present: Option<bool>,
pub effects: Vec<String>,
}
impl PlanArtifact {
pub fn new(inputs: PlanInputs<'_>) -> Result<Self> {
if inputs.effects.is_empty() || inputs.effects.iter().any(String::is_empty) {
return Err(Error::refuse(
WireReason::ProviderUnavailable,
"a plan must enumerate at least one non-empty effect",
));
}
let restores = inputs.operation.requires_restore_target_digest();
match (&inputs.restore_target_digest, restores) {
(None, true) => {
return Err(Error::refuse(
WireReason::ProviderUnavailable,
"a restore plan must name the exact target it will produce",
));
}
(Some(_), false) => {
return Err(Error::refuse(
WireReason::ProviderUnavailable,
format!(
"a {} plan must not name a restored target digest",
inputs.operation
),
));
}
_ => {}
}
if !inputs.end_state.is_empty() && inputs.operation != Operation::Remove {
return Err(Error::refuse(
WireReason::ProviderUnavailable,
format!(
"a {} plan must not carry per-path end states; only remove does",
inputs.operation
),
));
}
if inputs.end_state.iter().any(|entry| {
entry.path.is_empty()
|| (entry.end_state != EndState::REMOVED
&& entry.end_state != EndState::FINAL_BYTES)
|| (entry.survives()
!= (entry.member.is_some()
&& entry.sha256.is_some()
&& entry.byte_length.is_some()))
}) {
return Err(Error::refuse(
WireReason::ProviderUnavailable,
"an end state names a path and is removed, or final_bytes with member, \
sha256 and byte_length",
));
}
let software = Operation::SOFTWARE.contains(&inputs.operation);
match (
software,
inputs.software_prefix.filter(|value| !value.is_empty()),
inputs.software_version.filter(|value| !value.is_empty()),
) {
(true, Some(_), Some(_)) | (false, None, None) => {}
(true, _, _) => {
return Err(Error::refuse(
WireReason::ProviderUnavailable,
"a software plan must bind --prefix and the exact software version",
));
}
(false, _, _) => {
return Err(Error::refuse(
WireReason::ProviderUnavailable,
format!(
"a {} plan must not bind a software prefix or version",
inputs.operation
),
));
}
}
Ok(Self::assemble(inputs))
}
fn assemble(inputs: PlanInputs<'_>) -> Self {
Self {
format: PLAN_FORMAT.to_owned(),
protocol_version: PROTOCOL_VERSION,
provider_id: inputs.provider_id.to_owned(),
provider_version: inputs.provider_version.to_owned(),
provider_build_digest: inputs.provider_build_digest.to_owned(),
provider_release_digest: inputs.provider_release_digest.to_owned(),
operation_id: inputs.operation_id.to_owned(),
operation: inputs.operation.as_str().to_owned(),
canonical_target: inputs.canonical_target.to_owned(),
expected_target_digest: inputs.expected_target_digest.to_owned(),
projection_profile_digest: inputs.projection_profile_digest.to_owned(),
target_scope: inputs.target_scope.map(|scope| scope.as_str().to_owned()),
bundle: inputs.bundle,
backup_ref: inputs.backup_ref,
restore_target_digest: inputs.restore_target_digest,
native_capture: inputs.native_capture,
permission_profile: inputs.permission_profile,
platform: platform::echo(),
expires_at: inputs.expires_at.to_owned(),
software_prefix: inputs.software_prefix.map(str::to_owned),
software_version: inputs.software_version.map(str::to_owned),
software_artifacts: inputs.software_artifacts,
end_state: inputs.end_state,
instruction_path: inputs.instruction_path,
instruction_text: inputs.instruction_text,
instruction_observed_digest: inputs.instruction_observed_digest,
instruction_observed_present: inputs.instruction_observed_present,
effects: inputs.effects,
}
}
pub fn digest(&self) -> Result<String> {
let value = serde_json::to_value(self).map_err(|source| {
Error::refuse(
WireReason::ProviderUnavailable,
format!("the plan artifact cannot be encoded: {source}"),
)
})?;
digest::of_domain_canonical_json(PLAN_DOMAIN, &value).map_err(Error::from)
}
pub fn into_response(self) -> Result<serde_json::Value> {
let plan_digest = self.digest()?;
let mut response = serde_json::Map::new();
response.insert("state".to_owned(), serde_json::json!("planned"));
response.insert("plan_digest".to_owned(), serde_json::json!(plan_digest));
response.insert(
"effects".to_owned(),
serde_json::json!(self.effects.clone()),
);
response.insert(
"expected_target_digest".to_owned(),
serde_json::json!(self.expected_target_digest.clone()),
);
if let Some(bundle) = self.bundle.clone() {
insert_bundle_echo(&mut response, &bundle);
response.insert("valid".to_owned(), serde_json::json!(true));
}
let artifact = serde_json::to_value(self).map_err(|source| {
Error::refuse(
WireReason::ProviderUnavailable,
format!("the plan artifact cannot be encoded: {source}"),
)
})?;
response.insert("plan".to_owned(), artifact);
Ok(serde_json::Value::Object(response))
}
}
#[must_use]
pub fn bundle_accepted(bundle: &BundleBinding) -> serde_json::Value {
let mut response = serde_json::Map::new();
insert_bundle_echo(&mut response, bundle);
response.insert("valid".to_owned(), serde_json::json!(true));
serde_json::Value::Object(response)
}
#[must_use]
pub fn bundle_rejected(bundle: &BundleBinding, reason: WireReason) -> serde_json::Value {
rejected_with_detail(bundle, reason, None)
}
#[must_use]
pub fn rejected_with_detail(
bundle: &BundleBinding,
reason: WireReason,
detail: Option<&str>,
) -> serde_json::Value {
let mut response = serde_json::Map::new();
insert_bundle_echo(&mut response, bundle);
response.insert("rejected".to_owned(), serde_json::json!(true));
response.insert("reason".to_owned(), serde_json::json!(reason.as_str()));
if let Some(text) = detail {
response.insert("detail".to_owned(), serde_json::json!(text));
}
serde_json::Value::Object(response)
}
fn insert_bundle_echo(
response: &mut serde_json::Map<String, serde_json::Value>,
b: &BundleBinding,
) {
response.insert(
"bundle_format".to_owned(),
serde_json::json!(b.bundle_format),
);
response.insert(
"bundle_digest".to_owned(),
serde_json::json!(b.bundle_digest),
);
response.insert(
"artifact_digest".to_owned(),
serde_json::json!(b.artifact_digest),
);
response.insert("bundle_size".to_owned(), serde_json::json!(b.bundle_size));
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
const DIGEST: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111";
fn binding() -> BundleBinding {
BundleBinding {
bundle_format: "ai-stp-bundle/1".to_owned(),
bundle_digest: DIGEST.to_owned(),
artifact_digest: DIGEST.to_owned(),
bundle_size: 4096,
}
}
fn inputs(operation: Operation) -> PlanInputs<'static> {
PlanInputs {
target_scope: None,
software_artifacts: Vec::new(),
software_prefix: None,
software_version: None,
end_state: Vec::new(),
provider_id: "claude-setup-system",
provider_version: "0.1.0",
provider_build_digest: DIGEST,
provider_release_digest: DIGEST,
operation_id: "operation_01TEST",
operation,
canonical_target: "/tmp/target",
expected_target_digest: DIGEST,
projection_profile_digest: DIGEST,
bundle: Some(binding()),
backup_ref: Some("slot-000000000001".to_owned()),
restore_target_digest: None,
native_capture: None,
permission_profile: Some("default".to_owned()),
expires_at: "2026-08-23T15:00:00Z",
instruction_path: None,
instruction_text: None,
instruction_observed_digest: None,
instruction_observed_present: None,
effects: vec!["write settings.json".to_owned()],
}
}
#[test]
fn the_artifact_carries_exactly_the_members_the_consumer_compares() {
let artifact = PlanArtifact::new(inputs(Operation::Install)).unwrap();
let encoded = serde_json::to_value(&artifact).unwrap();
let mut present: Vec<&str> = encoded
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
present.sort_unstable();
assert_eq!(
present,
vec![
"backup_ref",
"bundle",
"canonical_target",
"effects",
"expected_target_digest",
"expires_at",
"format",
"operation",
"operation_id",
"permission_profile",
"platform",
"projection_profile_digest",
"protocol_version",
"provider_build_digest",
"provider_id",
"provider_release_digest",
"provider_version",
"restore_target_digest",
]
);
}
#[test]
fn the_digest_is_reproducible_and_domain_separated() {
let artifact = PlanArtifact::new(inputs(Operation::Install)).unwrap();
let once = artifact.digest().unwrap();
assert_eq!(once, artifact.digest().unwrap());
assert!(once.starts_with("sha256:"));
let value = serde_json::to_value(&artifact).unwrap();
assert_ne!(once, crate::setup_core::digest::of_canonical_json(&value).unwrap());
}
#[test]
fn changing_one_planned_field_changes_the_digest() {
let base = PlanArtifact::new(inputs(Operation::Install))
.unwrap()
.digest()
.unwrap();
let mut other = inputs(Operation::Install);
other.operation_id = "operation_01OTHER";
let changed = PlanArtifact::new(other).unwrap().digest().unwrap();
assert_ne!(base, changed);
}
#[test]
fn a_restore_plan_must_name_the_target_it_will_produce() {
let error = PlanArtifact::new(inputs(Operation::Restore)).unwrap_err();
assert!(error.detail().contains("restore plan"));
let mut good = inputs(Operation::Restore);
good.restore_target_digest = Some(DIGEST.to_owned());
assert!(PlanArtifact::new(good).is_ok());
}
#[test]
fn a_non_restore_plan_must_not_name_one() {
let mut wrong = inputs(Operation::Install);
wrong.restore_target_digest = Some(DIGEST.to_owned());
let error = PlanArtifact::new(wrong).unwrap_err();
assert!(error.detail().contains("must not name"));
}
#[test]
fn an_empty_effect_list_is_refused_before_it_reaches_the_consumer() {
let mut none = inputs(Operation::Install);
none.effects = Vec::new();
assert!(PlanArtifact::new(none).is_err());
let mut blank = inputs(Operation::Install);
blank.effects = vec![String::new()];
assert!(PlanArtifact::new(blank).is_err());
}
#[test]
fn the_response_repeats_the_digest_target_and_bundle_beside_the_plan() {
let artifact = PlanArtifact::new(inputs(Operation::Install)).unwrap();
let expected_digest = artifact.digest().unwrap();
let response = artifact.into_response().unwrap();
assert_eq!(response["state"], "planned");
assert_eq!(response["plan_digest"], expected_digest.as_str());
assert_eq!(response["expected_target_digest"], DIGEST);
assert_eq!(
response["effects"],
serde_json::json!(["write settings.json"])
);
assert_eq!(response["bundle_format"], "ai-stp-bundle/1");
assert_eq!(response["bundle_size"], 4096);
assert_eq!(response["valid"], true);
let nested = response["plan"].clone();
assert_eq!(
crate::setup_core::digest::of_domain_canonical_json(PLAN_DOMAIN, &nested).unwrap(),
expected_digest
);
}
#[test]
fn a_plan_without_a_bundle_carries_no_bundle_echo_or_validity_claim() {
let mut none = inputs(Operation::Backup);
none.bundle = None;
let response = PlanArtifact::new(none).unwrap().into_response().unwrap();
let object = response.as_object().unwrap();
assert!(!object.contains_key("bundle_format"));
assert!(!object.contains_key("valid"));
assert_eq!(response["plan"]["bundle"], serde_json::Value::Null);
}
#[test]
fn a_refusal_still_echoes_the_bytes_it_refuses() {
let response = bundle_rejected(&binding(), WireReason::PathEscapesTarget);
assert_eq!(response["rejected"], true);
assert_eq!(response["reason"], "path_escapes_target");
assert_eq!(response["bundle_digest"], DIGEST);
assert_eq!(response["bundle_size"], 4096);
assert!(response.get("valid").is_none());
}
#[test]
fn every_reason_spelling_fits_the_pattern_the_consumer_accepts() {
for reason in WireReason::ALL {
let text = reason.as_str();
assert!((1..=64).contains(&text.len()), "{text} is out of range");
assert!(
text.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'),
"{text} has a character the consumer will not read"
);
}
}
#[test]
fn acceptance_and_refusal_are_never_both_claimed() {
let accepted = bundle_accepted(&binding());
assert_eq!(accepted["valid"], true);
assert!(accepted.get("rejected").is_none());
}
#[test]
fn a_plan_without_end_states_carries_no_trace_of_the_member() {
let artifact = PlanArtifact::new(inputs(Operation::Remove)).unwrap();
let encoded = serde_json::to_value(&artifact).unwrap();
assert!(
!encoded.as_object().unwrap().contains_key("end_state"),
"{encoded}"
);
}
#[test]
fn a_remove_may_name_what_each_path_becomes_and_the_digest_binds_it() {
let bare = PlanArtifact::new(inputs(Operation::Remove)).unwrap();
let mut stated = inputs(Operation::Remove);
stated.end_state = vec![
EndState::removed("skills"),
EndState::final_bytes(
"settings.json",
"files/settings.json",
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
12,
),
];
let artifact = PlanArtifact::new(stated).unwrap();
let encoded = serde_json::to_value(&artifact).unwrap();
assert_eq!(encoded["end_state"][0]["end_state"], "removed");
assert!(encoded["end_state"][0].get("member").is_none());
assert_eq!(encoded["end_state"][1]["end_state"], "final_bytes");
assert_eq!(encoded["end_state"][1]["member"], "files/settings.json");
assert_eq!(encoded["end_state"][1]["byte_length"], 12);
assert_ne!(
bare.digest().unwrap(),
artifact.digest().unwrap(),
"two plans that leave different bytes behind cannot share a digest"
);
}
#[test]
fn an_end_state_on_anything_but_remove_is_refused_before_it_is_planned() {
for operation in [Operation::Install, Operation::Replace, Operation::Backup] {
let mut stated = inputs(operation);
stated.end_state = vec![EndState::removed("skills")];
let error = PlanArtifact::new(stated).unwrap_err();
assert!(
error.detail().contains("only remove does"),
"{operation}: {}",
error.detail()
);
}
}
#[test]
fn an_end_state_that_is_half_stated_is_refused() {
let mut stated = inputs(Operation::Remove);
stated.end_state = vec![EndState {
path: "settings.json".to_owned(),
end_state: EndState::FINAL_BYTES.to_owned(),
member: Some("files/settings.json".to_owned()),
sha256: None,
byte_length: None,
}];
assert!(PlanArtifact::new(stated).is_err());
let mut invented = inputs(Operation::Remove);
invented.end_state = vec![EndState {
path: "settings.json".to_owned(),
end_state: "kept".to_owned(),
member: None,
sha256: None,
byte_length: None,
}];
assert!(PlanArtifact::new(invented).is_err());
}
}