use super::super::plan as deploy_plan;
use super::*;
use crate::test_support::TempDir;
use canic_core::{
CANIC_WASM_CHUNK_BYTES,
cdk::utils::hash::{sha256_hex, wasm_hash_hex},
ids::{AppId, CanonicalNetworkId, FleetId},
};
use canic_host::{
canister_build::CanisterBuildProfile,
fleet_catalog::FleetCatalogEntryV1,
fleet_install_plan::{FreshFleetOperatorFundingEvidenceV1, PlannedCanisterCreationFunding},
release_build::{finalize_release_build_from_manifest, plan_release_build_for_profile},
};
use serde_json::Value as JsonValue;
use std::{ffi::OsString, fs, path::PathBuf};
const SAMPLE_CONFIG: &str = r#"
[app]
name = "demo"
init_mode = "enabled"
[roles.root]
kind = "root"
package = "root"
[roles.user_hub]
kind = "canister"
package = "user_hub"
[component_specs.user_hub]
component_role = "user_hub"
maximum_instances = 1
"#;
const USER_HUB_ARTIFACT: &[u8] = b"user-hub-artifact";
macro_rules! build_report {
($options:expr, $roots:expr $(,)?) => {
deploy_plan::build_report_with_operator_observer(
$options,
$roots,
&|principal, maximum_debit| Ok::<_, String>(operator_funding(principal, maximum_debit)),
)
};
}
fn operator_funding(
principal: &str,
maximum_debit: &PlannedCanisterCreationFunding,
) -> FreshFleetOperatorFundingEvidenceV1 {
let balance = match maximum_debit {
PlannedCanisterCreationFunding::Cycles { .. } => PlannedCanisterCreationFunding::Cycles {
cycles: 5_000_000_000_000_000,
},
PlannedCanisterCreationFunding::Icp { .. } => {
PlannedCanisterCreationFunding::Icp { e8s: u64::MAX }
}
};
FreshFleetOperatorFundingEvidenceV1 {
principal: principal.to_string(),
funding_account: "test-operator".to_string(),
balance,
source: "test_fixture".to_string(),
observed_at_unix_secs: 1_782_432_100,
valid_until_unix_secs: 4_102_444_800,
balance_fresh: true,
}
}
const MALFORMED_DESIRED_CONFIG: &str = r#"
unknown = true
[app]
name = "demo"
"#;
const SAMPLE_FLEET_INPUT: &str = r#"schema_version = 1
funding_profile = "single_subnet"
operator = "ryjl3-tyaaa-aaaaa-aaaba-cai"
[admission]
principals = ["ryjl3-tyaaa-aaaaa-aaaba-cai"]
[coordinator.subnet]
kind = "explicit"
subnet = "pzp6e-ekpqk-3c5x7-2h6so-njoeq-mt45d-h3h6c-q3mxf-vpeq5-fk5o7-yae"
acknowledge_fiduciary_cost = false
[coordinator.creation_funding]
kind = "cycles"
cycles = "100T"
[coordinator.root_funding]
minimum_reserve_cycles = "30T"
window_secs = 7776000
maximum_cycles = "30T"
maximum_automatic_grants = 4
maximum_automatic_cycles = "120T"
[[fleet_subnet_roots]]
placement_subnet = "pzp6e-ekpqk-3c5x7-2h6so-njoeq-mt45d-h3h6c-q3mxf-vpeq5-fk5o7-yae"
acknowledge_fiduciary_cost = false
[fleet_subnet_roots.component_admissions]
user_hub = 1
[fleet_subnet_roots.limits]
maximum_component_instances = 1
maximum_registry_bytes = 4194304
maximum_wasm_store_bytes = 40000000
maximum_group_placements = 0
[fleet_subnet_roots.limits.cycles_funding]
window_secs = 3600
maximum_cycles = "10T"
[fleet_subnet_roots.canister_pool]
minimum_size = 1
maximum_size = 4
canister_cycles = "1T"
imports = []
[fleet_subnet_roots.root_funding]
request_threshold = "10T"
target_balance = "30T"
cooldown_secs = 2592000
window_secs = 7776000
maximum_cycles = "30T"
maximum_automatic_grants = 4
maximum_automatic_cycles = "120T"
[fleet_subnet_roots.root_creation_funding]
kind = "cycles"
cycles = "30T"
[fleet_subnet_roots.wasm_store_creation_funding]
kind = "cycles"
cycles = "10T"
"#;
const POOL_CONFIG: &str = r#"
[app]
name = "demo"
init_mode = "enabled"
[roles.root]
kind = "root"
package = "root"
[roles.user_hub]
kind = "canister"
package = "user_hub"
[roles.user_shard]
kind = "canister"
package = "user_shard"
[component_specs.user_hub]
component_role = "user_hub"
maximum_instances = 1
[component_specs.user_hub.sharding.pools.user_shards]
canister_role = "user_shard"
policy.capacity = 100
policy.max_shards = 4
[component_specs.user_hub.children.user_shard]
kind = "shard"
[component_specs.user_hub.spawn_grants.user_hub.user_shard]
maximum_instances_per_parent = 20_000
"#;
#[test]
fn deploy_plan_is_top_level_deploy_command() {
let parsed = parse_subcommand(
deploy_command(),
[OsString::from("plan"), OsString::from("demo-local")],
)
.expect("parse deploy plan command")
.expect("deploy plan command");
assert_eq!(parsed.0, "plan");
assert_eq!(parsed.1, vec![OsString::from("demo-local")]);
let help = usage();
assert!(help.contains("canic deploy plan demo"));
assert!(help.contains("Deploy commands do not perform IC update calls"));
assert!(help.contains("canic install"));
}
#[test]
fn deploy_plan_help_documents_no_mutation_contract() {
let help = deploy_plan::usage();
assert!(help.contains("canic deploy plan <fleet> --app <app> --fleet-input <PATH>"));
assert!(help.contains("canic --environment ic deploy plan demo --app demo --fleet-input"));
assert!(help.contains("--refresh-catalog"));
assert!(help.contains("Read-only deployment planning"));
assert!(help.contains("queries its relevant ledger account and balance"));
assert!(help.contains("catalog evidence is used"));
assert!(help.contains("Registry queries"));
assert!(help.contains("private .canic/ic-query cache"));
assert!(help.contains("No mode builds, changes deployment state"));
assert!(help.contains("Put the top-level --environment before deploy"));
assert_eq!(help.matches(" canic deploy plan ").count(), 1);
}
#[test]
fn deploy_plan_options_parse_supported_surface() {
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--json"),
OsString::from("--out"),
OsString::from("deployment-plan.json"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
OsString::from("--profile"),
OsString::from("fast"),
OsString::from("--refresh-catalog"),
OsString::from("--release-build"),
OsString::from("01".repeat(32)),
OsString::from(crate::cli::globals::INTERNAL_ENVIRONMENT_OPTION),
OsString::from("local"),
OsString::from(crate::cli::globals::INTERNAL_ICP_OPTION),
OsString::from("custom-icp"),
])
.expect("parse deploy plan options");
assert_eq!(options.fleet, "demo-local");
assert_eq!(options.environment, "local");
assert_eq!(options.icp, "custom-icp");
assert!(options.json);
assert_eq!(options.out, Some(PathBuf::from("deployment-plan.json")));
assert_eq!(options.fleet_input, PathBuf::from("fleet-input.toml"));
assert!(options.refresh_catalog);
assert_eq!(
options.profile,
Some(canic_host::canister_build::CanisterBuildProfile::Fast)
);
assert_eq!(
options
.release_build_id
.map(|identity| identity.to_string()),
Some("01".repeat(32))
);
}
#[test]
fn deploy_plan_defaults_to_cache_only_catalog_loading() {
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
assert!(!options.refresh_catalog);
}
#[test]
fn deploy_plan_hard_cuts_old_config_and_build_profile_options() {
for obsolete in ["--config", "--build-profile"] {
let error = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
OsString::from(obsolete),
OsString::from("obsolete"),
])
.expect_err("obsolete plan spelling must reject");
assert!(matches!(error, DeployCommandError::Usage(_)));
}
}
#[test]
fn deploy_plan_loads_one_exact_finalized_release_source_without_allocation() {
let (_temp, workspace_root, icp_root) =
temp_plan_workspace("canic-deploy-plan-finalized-source");
let planned = plan_release_build_for_profile(&icp_root, CanisterBuildProfile::Fast)
.expect("plan fixture release build");
let manifest = icp_root.join("release-set.json");
fs::write(&manifest, b"fixture release set").expect("write release-set fixture");
let finalized =
finalize_release_build_from_manifest(&icp_root, planned.record.release_build_id, &manifest)
.expect("finalize fixture release build");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
OsString::from("--release-build"),
OsString::from(finalized.record.release_build_id.to_string()),
])
.expect("parse exact release source");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root: icp_root.clone(),
},
);
let json = serde_json::to_value(&report).expect("serialize report");
assert_eq!(json["build_profile"], "fast");
assert_eq!(
json["release_build_id"],
finalized.record.release_build_id.to_string()
);
assert_eq!(
json["fresh_fleet_plan"]["preflight"]["build_profile"],
"fast"
);
assert_eq!(
fs::read_dir(icp_root.join(".canic/release-builds"))
.expect("read release-build directory")
.count(),
1
);
}
#[test]
fn deploy_plan_options_reject_invalid_app_before_path_resolution() {
let error = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("../../sentinel"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect_err("invalid App path identity must reject");
assert!(matches!(error, DeployCommandError::Usage(_)));
assert!(error.to_string().contains("invalid App name"));
}
#[test]
fn deploy_plan_report_builds_from_config_without_fleet_catalog_entry() {
let (_temp, workspace_root, icp_root) = temp_plan_workspace("canic-deploy-plan-report");
write_artifact(&icp_root, "root", b"root-artifact");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["schema_version"], 1);
assert_eq!(json["command"], "canic deploy plan");
assert_eq!(json["fleet"], "demo-local");
assert_eq!(json["app"], "demo");
assert!(
json["fleet_input_path"]
.as_str()
.is_some_and(|path| path.ends_with("fleet-input.toml"))
);
assert_eq!(
json["fresh_fleet_plan"]["preflight"]["effects"]["build_started"],
false
);
assert_eq!(
json["fresh_fleet_plan"]["preflight"]["effects"]["workspace_mutation_started"],
false
);
assert_eq!(
json["fresh_fleet_plan"]["preflight"]["effects"]["ic_mutation_started"],
false
);
assert_eq!(json["status"], "warning");
assert_eq!(json["comparison_status"], "not_available");
assert_eq!(json["catalog_acquisition"]["kind"], "not_required");
assert_eq!(json["catalog_acquisition"]["network"], "local");
assert!(
json["fresh_fleet_plan"]["authority"]["catalog"]
.get("cache_disposition")
.is_none()
);
assert!(
json["fresh_fleet_plan"]["authority"]["catalog"]
.get("collected_at")
.is_none()
);
assert_eq!(
json["plan"]["deployment_identity"]["fleet_name"],
"demo-local"
);
assert_eq!(json["plan"]["deployment_identity"]["app"], "demo");
assert_eq!(
json["plan"]["plan_digest"],
json["fresh_fleet_plan"]["plan_digest"]
);
assert_base_plan_verified_facts(&json);
assert!(
json["warnings"]
.as_array()
.expect("warnings")
.iter()
.any(|item| item["code"] == "observed_inventory_unavailable")
);
assert_next_action(
&json,
"run the matching initial install workflow to build and finalize named-environment artifacts, or select an existing finalized --release-build",
);
assert_proposed_operation_keys(
&json,
&[
"future_apply_preview|create_canister|fleet_coordinator|not_executed",
"future_apply_preview|create_canister|root|not_executed",
"future_apply_preview|create_canister|user_hub|not_executed",
"future_apply_preview|create_canister|wasm_store|not_executed",
"future_apply_preview|install_wasm|fleet_coordinator|not_executed",
"future_apply_preview|install_wasm|root|not_executed",
"future_apply_preview|install_wasm|user_hub|not_executed",
"future_apply_preview|install_wasm|wasm_store|not_executed",
"future_apply_preview|register_child|fleet_coordinator|not_executed",
"future_apply_preview|register_child|user_hub|not_executed",
"future_apply_preview|register_child|wasm_store|not_executed",
"future_apply_preview|register_root|root|not_executed",
"future_apply_preview|upload_artifact|fleet_coordinator|not_executed",
"future_apply_preview|upload_artifact|root|not_executed",
"future_apply_preview|upload_artifact|user_hub|not_executed",
"future_apply_preview|upload_artifact|wasm_store|not_executed",
"future_apply_preview|verify_topology|demo-local|not_executed",
],
);
}
#[test]
fn deploy_plan_blocks_when_live_operator_funding_cannot_be_observed() {
let (_temp, workspace_root, icp_root) =
temp_plan_workspace("canic-deploy-plan-operator-observation");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = deploy_plan::build_report_with_operator_observer(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
&|_, _| Err::<FreshFleetOperatorFundingEvidenceV1, _>("ledger unavailable"),
);
let json = serde_json::to_value(&report).expect("serialize report");
assert!(json["fresh_fleet_plan"].is_null());
assert!(json["blockers"].as_array().is_some_and(|blockers| {
blockers.iter().any(|blocker| {
blocker["source"] == "local_observation"
&& blocker["category"] == "observation"
&& blocker["detail"]
.as_str()
.is_some_and(|detail| detail.contains("ledger unavailable"))
&& blocker["next"]
.as_str()
.is_some_and(|next| next.contains("authorized ICP identity"))
})
}));
}
#[test]
fn deploy_plan_catalog_identity_does_not_invent_one_root_fact() {
let (_temp, workspace_root, icp_root) =
temp_plan_workspace("canic-deploy-plan-coordinator-catalog");
write_artifact(&icp_root, "root", b"root-artifact");
write_fleet_catalog(
&icp_root,
"local",
sample_fleet_catalog_entry("demo-local", "rrkah-fqaaa-aaaaa-aaaaq-cai"),
);
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["comparison_status"], "not_requested");
assert_eq!(
json["plan"]["trust_domain"]["root_trust_anchor"],
JsonValue::Null
);
assert!(
json["verified_facts"]
.as_array()
.expect("verified facts")
.iter()
.all(|item| item["code"] != "installed_root_canister_id_resolved")
);
assert!(
json["verified_facts"]
.as_array()
.expect("verified facts")
.iter()
.all(|item| item["code"] != "root_trust_anchor_resolved")
);
}
#[test]
fn deploy_plan_report_keeps_complete_inputs_planned_without_root_comparison() {
let (_temp, workspace_root, icp_root) = temp_plan_workspace("canic-deploy-plan-compared");
write_complete_local_plan_inputs(&icp_root);
write_fleet_catalog(
&icp_root,
"local",
sample_fleet_catalog_entry("demo-local", "rrkah-fqaaa-aaaaa-aaaaq-cai"),
);
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(
json["status"], "planned",
"complete local plan unexpectedly emitted diagnostics: {json:#}"
);
assert_eq!(json["comparison_status"], "not_requested");
assert_eq!(json["blockers"], JsonValue::Array(vec![]));
assert_eq!(json["warnings"], JsonValue::Array(vec![]));
assert_eq!(json["assumptions"], JsonValue::Array(vec![]));
assert_verified_fact(
&json,
"artifact_set_resolved",
"demo-local",
"deployment_plan_builder",
);
assert_verified_fact(
&json,
"deployment_manifest_resolved",
"demo-local",
"deployment_plan_builder",
);
assert_verified_fact(
&json,
"role_artifact_observed",
"fleet_coordinator",
"local_observation",
);
assert_verified_fact(&json, "role_artifact_observed", "root", "local_observation");
assert_verified_fact(
&json,
"role_artifact_observed",
"user_hub",
"local_observation",
);
assert_verified_fact(
&json,
"role_artifact_observed",
"wasm_store",
"local_observation",
);
}
#[test]
fn deploy_plan_report_previews_pool_canister_creation() {
let (_temp, workspace_root, icp_root) =
temp_plan_workspace_with_config("canic-deploy-plan-pool-preview", POOL_CONFIG);
write_artifact(&icp_root, "root", b"root-artifact");
write_artifact(&icp_root, "user_hub", b"user-hub-artifact");
write_artifact(&icp_root, "user_shard", b"user-shard-artifact");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["plan"]["expected_pool"][0]["pool"], "user_shards");
assert_eq!(json["plan"]["expected_pool"][0]["role"], "user_shard");
assert_verified_fact(
&json,
"expected_pool_inventory_resolved",
"demo-local",
"deployment_plan_builder",
);
assert_proposed_operation(&json, "create_canister", "user_shards:user_shard");
assert_proposed_operation(&json, "register_child", "user_shards:user_shard");
}
#[test]
fn deploy_plan_report_blocks_unresolved_config_target() {
let temp = TempDir::new("canic-deploy-plan-missing-config");
let workspace_root = temp.join("workspace");
let icp_root = temp.join("icp");
fs::create_dir_all(&workspace_root).expect("create workspace");
fs::create_dir_all(&icp_root).expect("create icp root");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("missing"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["status"], "blocked");
assert_eq!(json["comparison_status"], "not_requested");
assert_eq!(json["blockers"][0]["code"], "app_unresolved");
assert_eq!(json["verified_facts"], JsonValue::Array(vec![]));
assert!(matches!(
deploy_plan::command_exit_result(&report),
Err(DeployCommandError::PlanBlocked(_))
));
}
#[test]
fn deploy_plan_blocks_invalid_fleet_input_without_allocating_release_state() {
let (_temp, workspace_root, icp_root) =
temp_plan_workspace("canic-deploy-plan-invalid-fleet-input");
fs::write(icp_root.join("fleet-input.toml"), "schema_version = 2\n")
.expect("write invalid Fleet input");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root: icp_root.clone(),
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["status"], "blocked");
assert_eq!(json["fresh_fleet_plan"], JsonValue::Null);
assert!(json["blockers"].as_array().is_some_and(|blockers| {
blockers.iter().any(|blocker| {
blocker["code"] == "fresh_fleet_plan_blocked" && blocker["source"] == "fleet_input"
})
}));
assert!(!icp_root.join(".canic/release-builds").exists());
}
#[test]
fn deploy_plan_report_blocks_invalid_fleet_name() {
let (_temp, workspace_root, icp_root) = temp_plan_workspace("canic-deploy-plan-invalid-target");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo/local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["status"], "blocked");
assert_eq!(json["comparison_status"], "not_requested");
assert_eq!(json["blockers"][0]["code"], "fleet_name_invalid");
assert_eq!(json["blockers"][0]["source"], "cli_arg");
assert_eq!(json["verified_facts"], JsonValue::Array(vec![]));
}
#[test]
fn deploy_plan_resolves_forwarded_environment_to_canonical_network() {
let (_temp, workspace_root, icp_root) =
temp_plan_workspace("canic-deploy-plan-environment-target");
fs::write(
icp_root.join("icp.yaml"),
"environments:\n - name: staging\n network: ic\n",
)
.expect("write ICP environment mapping");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
OsString::from(crate::cli::globals::INTERNAL_ENVIRONMENT_OPTION),
OsString::from("staging"),
])
.expect("parse forwarded plan environment");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root: icp_root.clone(),
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["environment"], "staging");
assert_eq!(
json["plan"]["deployment_identity"]["canonical_network_id"],
CanonicalNetworkId::ic_mainnet().to_string()
);
assert_eq!(json["catalog_failure"]["network"], "ic");
assert_eq!(json["catalog_failure"]["source_kind"], JsonValue::Null);
assert_eq!(
json["catalog_failure"]["source_endpoints"],
JsonValue::Array(vec![])
);
assert_eq!(json["catalog_failure"]["stage"], "cache_absence");
assert_eq!(
json["catalog_failure"]["cache_disposition"]["kind"],
"cache_missing"
);
assert_eq!(json["catalog_failure"]["registry_version"], JsonValue::Null);
assert_eq!(
json["catalog_failure"]["returned_registry_value_version"],
JsonValue::Null
);
assert_eq!(json["catalog_failure"]["source_endpoint"], JsonValue::Null);
assert_eq!(json["catalog_failure"]["assurance"], JsonValue::Null);
assert_eq!(
json["catalog_failure"]["registry_records"],
JsonValue::Array(vec![])
);
assert_eq!(
json["catalog_failure"]["retryability"]["kind"],
"not_retryable"
);
assert_eq!(json["catalog_failure"]["effects"]["build_started"], false);
assert_eq!(
json["catalog_failure"]["effects"]["workspace_mutation_started"],
false
);
assert_eq!(
json["catalog_failure"]["effects"]["ic_mutation_started"],
false
);
assert!(json["blockers"].as_array().is_some_and(|blockers| {
blockers.iter().any(|blocker| {
blocker["code"] == "fresh_fleet_plan_blocked" && blocker["source"] == "fleet_catalog"
})
}));
assert_catalog_refresh_remedy(&json);
let text = deploy_plan::render_text(&report);
assert!(text.contains("catalog failure provenance"));
assert!(text.contains("no_effects_started: true"));
assert!(text.contains("stage: cache_absence"));
assert!(text.contains("cache_disposition: cache_missing"));
assert!(text.contains("retryability: not_retryable"));
assert!(text.contains(
"effects: build_started=false workspace_mutation_started=false ic_mutation_started=false"
));
assert!(json["blockers"].as_array().is_some_and(|blockers| {
blockers
.iter()
.all(|blocker| blocker["code"] != "environment_mismatch")
}));
assert_verified_fact(
&json,
"environment_resolved",
"demo",
"deployment_plan_builder",
);
assert!(!icp_root.join(".canic/ic-query").exists());
}
#[test]
fn deploy_plan_catalog_acquisition_does_not_change_the_canonical_local_plan() {
let (_temp, workspace_root, icp_root) =
temp_plan_workspace("canic-deploy-plan-catalog-acquisition-parity");
let base_args = [
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
];
let cache_only =
deploy_plan::DeployPlanOptions::parse(base_args.clone()).expect("parse cache-only options");
let mut refresh_args = base_args.to_vec();
refresh_args.push(OsString::from("--refresh-catalog"));
let refresh =
deploy_plan::DeployPlanOptions::parse(refresh_args).expect("parse refresh options");
let roots = deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
};
let cache_only_digest = plan_digest_from_report(&build_report!(&cache_only, &roots));
let refreshed_digest = plan_digest_from_report(&build_report!(&refresh, &roots));
assert_eq!(refreshed_digest, cache_only_digest);
}
#[test]
fn deploy_plan_blocks_contradictory_environment_profile() {
let (_temp, workspace_root, icp_root) =
temp_plan_workspace("canic-deploy-plan-environment-mismatch");
fs::write(
icp_root.join("icp.yaml"),
"environments:\n - name: staging\n network: ic\n",
)
.expect("write ICP environment mapping");
write_local_network_authority(&icp_root, "staging");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
OsString::from(crate::cli::globals::INTERNAL_ENVIRONMENT_OPTION),
OsString::from("staging"),
])
.expect("parse forwarded plan environment");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["status"], "blocked");
assert!(json["blockers"].as_array().is_some_and(|blockers| {
blockers
.iter()
.any(|blocker| blocker["code"] == "environment_mismatch")
}));
assert_eq!(
json["plan"]["deployment_identity"]["canonical_network_id"],
JsonValue::Null
);
assert_no_verified_fact(&json, "environment_resolved");
}
#[test]
fn deploy_plan_report_blocks_malformed_desired_config() {
let (temp, workspace_root, icp_root) = temp_plan_workspace_with_config(
"canic-deploy-plan-malformed-config",
MALFORMED_DESIRED_CONFIG,
);
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["status"], "blocked");
assert_eq!(json["comparison_status"], "not_requested");
assert!(
json["verified_facts"]
.as_array()
.expect("verified facts")
.iter()
.any(|item| item["code"] == "fleet_app_resolved")
);
assert_verified_fact(
&json,
"authority_profile_resolved",
"demo-local",
"deployment_plan_builder",
);
assert_verified_fact(
&json,
"expected_controller_set_resolved",
"demo-local",
"deployment_plan_builder",
);
assert_no_verified_fact(&json, "expected_canister_inventory_resolved");
assert!(
json["blockers"]
.as_array()
.expect("blockers")
.iter()
.any(|item| item["code"] == "local_config_roles")
);
assert!(
json["assumptions"]
.as_array()
.expect("assumptions")
.iter()
.all(|item| !item["code"]
.as_str()
.unwrap_or_default()
.starts_with("local_config_"))
);
assert!(matches!(
deploy_plan::command_exit_result(&report),
Err(DeployCommandError::PlanBlocked(_))
));
drop(temp);
}
#[test]
fn deploy_plan_json_out_is_create_new_and_json_only() {
let (_temp, workspace_root, icp_root) = temp_plan_workspace("canic-deploy-plan-out");
let out = workspace_root.join("reports").join("deployment-plan.json");
fs::create_dir_all(out.parent().expect("report parent")).expect("create report parent");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
OsString::from("--out"),
OsString::from(out.as_os_str()),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
deploy_plan::write_report(&options, &report).expect("write report");
let written = fs::read_to_string(&out).expect("read report");
let json: JsonValue = serde_json::from_str(&written).expect("out should be json");
assert_eq!(json["schema_version"], 1);
assert_eq!(json["command"], "canic deploy plan");
assert_eq!(
written,
format!(
"{}\n",
deploy_plan::render_json(&report).expect("render report json")
)
);
assert!(!written.contains("Deployment plan"));
assert!(!written.contains("status:"));
let err = deploy_plan::write_report(&options, &report)
.expect_err("--out must not overwrite an existing report");
assert!(matches!(err, DeployCommandError::PlanOutput(_)));
assert_eq!(err.exit_code(), 2);
}
#[test]
fn deploy_plan_out_does_not_create_parent_directories() {
let (_temp, workspace_root, icp_root) = temp_plan_workspace("canic-deploy-plan-out-parent");
let report_dir = workspace_root.join("missing-reports");
let out = report_dir.join("deployment-plan.json");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
OsString::from("--out"),
OsString::from(out.as_os_str()),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let err = deploy_plan::write_report(&options, &report)
.expect_err("--out must not create parent directories");
assert!(matches!(err, DeployCommandError::PlanOutput(_)));
assert_eq!(err.exit_code(), 2);
assert!(!report_dir.exists());
}
#[test]
fn deploy_plan_json_renderer_is_report_only() {
let (_temp, workspace_root, icp_root) = temp_plan_workspace("canic-deploy-plan-json-render");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = deploy_plan::render_json(&report).expect("render report json");
let parsed: JsonValue = serde_json::from_str(&json).expect("json payload should parse");
assert_eq!(parsed["schema_version"], 1);
assert_eq!(parsed["command"], "canic deploy plan");
assert!(!json.contains("Deployment plan"));
assert!(!json.contains("next actions"));
assert_no_deploy_plan_safety_claims(&json);
}
#[test]
fn deploy_plan_json_renderer_uses_contract_field_order() {
let (_temp, workspace_root, icp_root) = temp_plan_workspace("canic-deploy-plan-json-order");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let json = deploy_plan::render_json(&report).expect("render report json");
assert_top_level_json_field_order(
&json,
&[
"schema_version",
"command",
"fleet",
"app",
"environment",
"fleet_input_path",
"build_profile",
"release_build_id",
"config_path",
"status",
"comparison_status",
"catalog_acquisition",
"catalog_failure",
"fresh_fleet_plan",
"plan",
"blockers",
"warnings",
"assumptions",
"verified_facts",
"proposed_operations",
"next_actions",
],
);
}
#[test]
fn workspace_plan_digest_tracks_build_inputs_but_excludes_report_outputs() {
let (_temp, workspace_root, icp_root) = temp_plan_workspace("canic-deploy-plan-source-digest");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let roots = deploy_plan::DeployPlanRoots {
workspace_root: workspace_root.clone(),
icp_root,
};
let first = plan_digest_from_report(&build_report!(&options, &roots));
fs::write(
workspace_root.join("deployment-plan.json"),
"report output\n",
)
.expect("write excluded report output");
let after_report = plan_digest_from_report(&build_report!(&options, &roots));
assert_eq!(after_report, first);
fs::write(workspace_root.join("Cargo.lock"), "# changed test lock\n")
.expect("change Cargo.lock");
let after_lock = plan_digest_from_report(&build_report!(&options, &roots));
assert_ne!(after_lock, first);
}
fn plan_digest_from_report(report: &impl serde::Serialize) -> String {
let value = serde_json::to_value(report).expect("serialize deployment plan report");
value["fresh_fleet_plan"]["plan_digest"]
.as_str()
.expect("complete fresh-Fleet plan digest")
.to_string()
}
fn assert_catalog_refresh_remedy(report: &JsonValue) {
let blocker = report["blockers"]
.as_array()
.and_then(|blockers| {
blockers
.iter()
.find(|blocker| blocker["source"] == "fleet_catalog")
})
.expect("catalog blocker");
let next = blocker["next"].as_str().expect("catalog blocker remedy");
assert!(next.contains("--refresh-catalog"));
assert!(!next.contains("repair the Fleet input"));
}
#[test]
fn deploy_plan_text_avoids_apply_safety_claims() {
let (_temp, workspace_root, icp_root) = temp_plan_workspace("canic-deploy-plan-text");
write_artifact(&icp_root, "root", b"root-artifact");
let options = deploy_plan::DeployPlanOptions::parse([
OsString::from("demo-local"),
OsString::from("--app"),
OsString::from("demo"),
OsString::from("--fleet-input"),
OsString::from("fleet-input.toml"),
])
.expect("parse deploy plan options");
let report = build_report!(
&options,
&deploy_plan::DeployPlanRoots {
workspace_root,
icp_root,
},
);
let text = deploy_plan::render_text(&report);
assert!(text.contains("Deployment plan"));
assert!(text.contains("schema_version: 1"));
assert!(text.contains("command: canic deploy plan"));
assert!(text.contains("catalog acquisition provenance"));
assert!(text.contains("kind: not_required"));
assert!(text.contains("canonical fresh-Fleet decision"));
assert!(text.contains("plan_digest: "));
assert!(text.contains("admission: generation=1 template_digest="));
assert!(text.contains("fleet_principals=1 narrower_rules=0 narrower_principal_references=0"));
assert!(text.contains("operator_principal: ryjl3-tyaaa-aaaaa-aaaba-cai"));
assert!(text.contains("maximum_operator_debit: 140000300000000 cycles"));
assert!(text.contains(
"operator_balance_evidence: source=test_fixture observed_at=1782432100 valid_until=4102444800 fresh=true sufficient=true"
));
assert!(text.contains(
"canister_counts: coordinator=1 root=1 store=1 component=0 ready_pool=1 role=3 total=4"
));
assert!(text.contains(
"root: subnet=pzp6e-ekpqk-3c5x7-2h6so-njoeq-mt45d-h3h6c-q3mxf-vpeq5-fk5o7-yae component=0 initial_pool=1 pool_creations=1 ready_pool=1 admissions=1 admission_projections=0"
));
assert!(text.contains(
"funding: category=coordinator_creation owner=Fleet Coordinator payer=operator count=1 per_canister=100000000000000 cycles maximum=100000000000000 cycles"
));
assert!(text.contains(
"funding: category=cycles_ledger_creation_fee owner=Operator-created infrastructure Canisters payer=operator count=3 per_canister=100000000 cycles maximum=300000000 cycles"
));
assert!(text.contains("future apply preview (proposed operation labels; not executed)"));
assert!(text.contains(
"phase: future_apply_preview label: upload_artifact subject: root status: not_executed"
));
assert!(text.contains(
"phase: future_apply_preview label: verify_topology subject: demo-local status: not_executed"
));
assert!(text.contains(
"run the matching initial install workflow to build and finalize named-environment artifacts, or select an existing finalized --release-build"
));
assert!(text.contains("source: app_config"));
assert!(text.contains("source: deployment_plan_builder"));
assert!(text.contains("source: fleet_catalog"));
assert_no_deploy_plan_safety_claims(&text);
}
fn temp_plan_workspace(prefix: &str) -> (TempDir, PathBuf, PathBuf) {
temp_plan_workspace_with_config(prefix, SAMPLE_CONFIG)
}
fn temp_plan_workspace_with_config(prefix: &str, config: &str) -> (TempDir, PathBuf, PathBuf) {
let temp = TempDir::new(prefix);
let workspace_root = temp.join("workspace");
let icp_root = temp.join("icp");
let config_dir = workspace_root.join("apps").join("demo");
fs::create_dir_all(&config_dir).expect("create config dir");
fs::create_dir_all(&icp_root).expect("create icp root");
fs::write(workspace_root.join("Cargo.lock"), "# test lock\n").expect("write Cargo.lock");
fs::write(config_dir.join("canic.toml"), config).expect("write config");
fs::write(icp_root.join("fleet-input.toml"), SAMPLE_FLEET_INPUT).expect("write Fleet input");
write_local_network_authority(&icp_root, "local");
(temp, workspace_root, icp_root)
}
fn write_fleet_catalog(
icp_root: &std::path::Path,
environment: &str,
mut fleet: FleetCatalogEntryV1,
) {
let network = write_local_network_authority(icp_root, environment);
fleet.canonical_network_id = network;
let path = icp_root
.join(".canic")
.join("networks")
.join(network.to_string())
.join("fleets/catalog.json");
fs::create_dir_all(path.parent().expect("catalog parent")).expect("create catalog dir");
fs::write(
path,
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 1,
"canonical_network_id": network,
"entries": [fleet],
}))
.expect("encode Fleet catalog"),
)
.expect("write Fleet catalog");
}
fn write_local_network_authority(
icp_root: &std::path::Path,
environment: &str,
) -> CanonicalNetworkId {
let root_key = test_local_root_key();
let network = CanonicalNetworkId::from_der_root_trust_anchor(&root_key)
.expect("canonical local network ID");
let authority = icp_root
.join(".canic")
.join("networks")
.join(network.to_string());
fs::create_dir_all(authority.join("trust")).expect("create network authority");
fs::write(authority.join("trust/root-key.der"), &root_key).expect("write root key");
fs::write(
authority.join("enrollment.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"root_key_digest": sha256_hex(&root_key),
"enrolled_at": 1,
"source_profile": environment,
}))
.expect("encode enrollment"),
)
.expect("write enrollment");
let profile = icp_root
.join(".canic")
.join("environment-profiles")
.join(environment)
.join("network.json");
fs::create_dir_all(profile.parent().expect("profile parent")).expect("create profile dir");
fs::write(
profile,
serde_json::to_vec_pretty(&serde_json::json!({
"canonical_network_id": network,
}))
.expect("encode profile"),
)
.expect("write profile");
network
}
fn test_local_root_key() -> Vec<u8> {
let mut root_key = vec![
0x30, 0x81, 0x82, 0x30, 0x1d, 0x06, 0x0d, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0xdc, 0x7c,
0x05, 0x03, 0x01, 0x02, 0x01, 0x06, 0x0c, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0xdc, 0x7c,
0x05, 0x03, 0x02, 0x01, 0x03, 0x61, 0x00,
];
root_key.extend_from_slice(&[9; 96]);
root_key
}
fn write_artifact(icp_root: &std::path::Path, role: &str, bytes: &[u8]) {
let path = icp_root
.join(".icp")
.join("local")
.join("canisters")
.join(role)
.join(format!("{role}.wasm.gz"));
fs::create_dir_all(path.parent().expect("artifact parent")).expect("create artifact dir");
fs::write(path, bytes).expect("write artifact");
}
fn write_complete_local_plan_inputs(icp_root: &std::path::Path) {
write_artifact(icp_root, "fleet_coordinator", b"fleet-coordinator-artifact");
write_artifact(icp_root, "root", b"root-artifact");
write_artifact(icp_root, "wasm_store", b"wasm-store-artifact");
write_artifact(icp_root, "user_hub", USER_HUB_ARTIFACT);
write_release_set_manifest(icp_root);
}
fn write_release_set_manifest(icp_root: &std::path::Path) {
let path = icp_root
.join(".icp")
.join("local")
.join("canisters")
.join("root")
.join("root.release-set.json");
let user_hub_hash = wasm_hash_hex(USER_HUB_ARTIFACT);
let candid_hash = sha256_hex(b"user-hub-candid");
let protocol_profile_digest = sha256_hex(b"user-hub-protocol-profile");
let manifest = serde_json::json!({
"release_version": "0.79.0",
"entries": [{
"role": "user_hub",
"template_id": "embedded:user_hub",
"artifact_relative_path": ".icp/local/canisters/user_hub/user_hub.wasm.gz",
"candid_sha256_hex": candid_hash,
"protocol_profile_digest_hex": protocol_profile_digest,
"payload_size_bytes": USER_HUB_ARTIFACT.len(),
"payload_sha256_hex": user_hub_hash,
"chunk_size_bytes": CANIC_WASM_CHUNK_BYTES,
"chunk_sha256_hex": [user_hub_hash]
}]
});
fs::create_dir_all(path.parent().expect("manifest parent")).expect("create manifest dir");
fs::write(
path,
serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
)
.expect("write manifest");
}
fn sample_fleet_catalog_entry(
fleet_name: &str,
coordinator_principal: &str,
) -> FleetCatalogEntryV1 {
FleetCatalogEntryV1 {
canonical_network_id: CanonicalNetworkId::ic_mainnet(),
fleet_id: FleetId::from_generated_bytes([9; 32]),
fleet_name: fleet_name.parse().expect("Fleet name"),
app: AppId::from("demo"),
environment: "local".to_string(),
deployed_at_unix_secs: 1,
release_build_id: "01".repeat(32).parse().expect("release build"),
coordinator_principal: coordinator_principal.to_string(),
}
}
fn assert_verified_fact(report: &JsonValue, code: &str, subject: &str, source: &str) {
assert!(
report["verified_facts"]
.as_array()
.expect("verified facts")
.iter()
.any(|item| {
item["code"] == code && item["subject"] == subject && item["source"] == source
}),
"missing verified fact {code} for {subject} from {source}: {:#}",
report["verified_facts"]
);
}
fn assert_no_verified_fact(report: &JsonValue, code: &str) {
assert!(
report["verified_facts"]
.as_array()
.expect("verified facts")
.iter()
.all(|item| item["code"] != code),
"unexpected verified fact {code}: {:#}",
report["verified_facts"]
);
}
fn assert_proposed_operation(report: &JsonValue, label: &str, subject: &str) {
assert!(
report["proposed_operations"]
.as_array()
.expect("proposed operations")
.iter()
.any(|item| {
item["phase"] == "future_apply_preview"
&& item["label"] == label
&& item["subject"] == subject
&& item["status"] == "not_executed"
}),
"missing proposed operation {label} for {subject}: {:#}",
report["proposed_operations"]
);
}
fn assert_next_action(report: &JsonValue, expected: &str) {
assert!(
report["next_actions"]
.as_array()
.expect("next actions")
.iter()
.any(|item| item == expected),
"missing next action {expected}: {:#}",
report["next_actions"]
);
}
fn assert_proposed_operation_keys(report: &JsonValue, expected: &[&str]) {
let actual = report["proposed_operations"]
.as_array()
.expect("proposed operations")
.iter()
.map(proposed_operation_key)
.collect::<Vec<_>>();
assert_eq!(actual, expected, "proposed operation keys");
}
fn proposed_operation_key(item: &JsonValue) -> String {
format!(
"{}|{}|{}|{}",
item["phase"].as_str().unwrap_or_default(),
item["label"].as_str().unwrap_or_default(),
item["subject"].as_str().unwrap_or_default(),
item["status"].as_str().unwrap_or_default()
)
}
fn assert_base_plan_verified_facts(report: &JsonValue) {
assert_no_verified_fact(report, "artifact_set_resolved");
for (code, subject, source) in [
(
"authority_profile_resolved",
"demo-local",
"deployment_plan_builder",
),
(
"canonical_runtime_config_resolved",
"demo-local",
"deployment_config",
),
("build_profile_resolved", "demo-local", "build_profile"),
("config_path_resolved", "demo-local", "deployment_config"),
("fleet_app_resolved", "demo-local", "app_config"),
(
"expected_controller_set_resolved",
"demo-local",
"deployment_plan_builder",
),
(
"expected_canister_inventory_resolved",
"demo-local",
"deployment_plan_builder",
),
(
"expected_role_artifact_inventory_resolved",
"demo-local",
"deployment_plan_builder",
),
(
"expected_pool_inventory_resolved",
"demo-local",
"deployment_plan_builder",
),
("app_resolved", "demo", "app_config"),
(
"environment_resolved",
"demo-local",
"deployment_plan_builder",
),
(
"pool_identity_set_resolved",
"demo-local",
"deployment_plan_builder",
),
("plan_id_resolved", "demo-local", "deployment_plan_builder"),
(
"planner_version_resolved",
"demo-local",
"deployment_plan_builder",
),
("role_artifact_observed", "root", "local_observation"),
(
"role_topology_resolved",
"demo-local",
"deployment_plan_builder",
),
(
"runtime_variant_resolved",
"demo-local",
"deployment_plan_builder",
),
] {
assert_verified_fact(report, code, subject, source);
}
}
fn assert_top_level_json_field_order(json: &str, fields: &[&str]) {
let mut last = 0;
for field in fields {
let pattern = format!("\n \"{field}\"");
let position = json
.find(&pattern)
.unwrap_or_else(|| panic!("missing top-level JSON field {field}: {json}"));
assert!(
position >= last,
"top-level JSON field {field} appeared out of order"
);
last = position;
}
}
fn assert_no_deploy_plan_safety_claims(rendered: &str) {
for phrase in [
"DeploymentPlanReport",
"EvidenceEnvelope",
"authorization to mutate",
"deployment is safe",
"deployment truth",
"ready to apply",
"ready_for_apply",
"ready_to_apply",
"safe to deploy",
"safe_to_deploy",
"will apply",
"will create",
"will install",
"will mutate",
"will register",
"will set",
"will upgrade",
"will upload",
] {
assert!(
!rendered.contains(phrase),
"deploy-plan output must not contain safety/evidence/truth claim {phrase:?}: {rendered}"
);
}
}