use super::*;
pub(super) fn validate_model_resolution(
role: &RoleInput,
target: TargetHarness,
resolution: &ModelResolution,
) -> Result<(), CompileError> {
if let Some(model) = &resolution.model {
validate_model_token(model)?;
}
if let Some(profile) = &resolution.profile {
validate_identifier("model profile", profile)?;
}
if let Some(effort) = &resolution.reasoning_effort {
validate_identifier("reasoning effort", effort)?;
}
let inherited = role.model_hint == "inherit-caller";
let valid = match target {
TargetHarness::Claude => {
resolution.model.is_some()
&& resolution.profile.is_none()
&& resolution.reasoning_effort.is_none()
}
TargetHarness::Codex if inherited => {
resolution.model.is_none()
&& resolution.profile.is_none()
&& resolution.reasoning_effort.is_none()
}
TargetHarness::Codex => {
resolution.model.is_some()
&& resolution.profile.is_some()
&& resolution.reasoning_effort.is_some()
}
TargetHarness::Pi if inherited => {
resolution.model.as_deref() == Some("inherit")
&& resolution.profile.is_none()
&& resolution.reasoning_effort.is_none()
}
TargetHarness::Pi => {
resolution.model.is_none()
&& resolution.profile.is_none()
&& resolution.reasoning_effort.is_none()
}
};
if !valid {
let detail = if target == TargetHarness::Codex && !inherited {
"model, profile, and reasoning effort"
} else {
"target-native model/profile fields"
};
return Err(CompileError::Invalid(format!(
"{}: model mapping for `{}` must provide {detail} for target `{}`",
role.source_path,
role.model_hint,
target.as_str()
)));
}
Ok(())
}
pub(super) fn validate_input(input: &CompileInput) -> Result<(), CompileError> {
if input.roles.is_empty() {
return Err(CompileError::Invalid("content has zero roles".into()));
}
if input.skills.is_empty() {
return Err(CompileError::Invalid("content has zero skills".into()));
}
let mut role_names = BTreeSet::new();
for role in &input.roles {
validate_identifier("role", &role.role)?;
if !role_names.insert(role.role.as_str()) {
return Err(CompileError::Invalid(format!(
"duplicate role `{}`",
role.role
)));
}
validate_planter_contract(role)?;
validate_canonical_dispatch_topology(role)?;
validate_description(&role.source_path, &role.description)?;
validate_source_path(&role.source_path)?;
validate_identifier("model hint", &role.model_hint)?;
validate_identifier("startup skill", &role.startup_skill)?;
if role.capabilities.is_empty() {
return Err(CompileError::Invalid(format!(
"{}: capabilities must not be empty",
role.source_path
)));
}
for capability in &role.capabilities {
validate_identifier("capability", capability)?;
}
if role.body.trim().is_empty() {
return Err(CompileError::Invalid(format!(
"{}: role body must not be empty",
role.source_path
)));
}
if role.source_content.is_empty() {
return Err(CompileError::Invalid(format!(
"{}: source content is empty",
role.source_path
)));
}
}
let mut skill_names = BTreeSet::new();
for skill in &input.skills {
validate_identifier("skill", &skill.name)?;
if !skill_names.insert(skill.name.as_str()) {
return Err(CompileError::Invalid(format!(
"duplicate skill `{}`",
skill.name
)));
}
validate_description(&skill.source_path, &skill.description)?;
validate_source_path(&skill.source_path)?;
if skill.body.trim().is_empty() {
return Err(CompileError::Invalid(format!(
"{}: skill body must not be empty",
skill.source_path
)));
}
if skill.source_content.is_empty() {
return Err(CompileError::Invalid(format!(
"{}: source content is empty",
skill.source_path
)));
}
let source_prefix = skill.source_path.strip_suffix("SKILL.md").ok_or_else(|| {
CompileError::Invalid(format!(
"{}: skill source must end with SKILL.md",
skill.source_path
))
})?;
let mut resource_paths = BTreeSet::new();
let mut resource_bytes = 0usize;
for resource in &skill.resources {
validate_resource_path(&resource.relative_path)?;
if !resource_paths.insert(resource.relative_path.as_str()) {
return Err(CompileError::Invalid(format!(
"{}: duplicate skill resource `{}`",
skill.source_path, resource.relative_path
)));
}
validate_source_path(&resource.source_path)?;
let expected_source = format!("{source_prefix}{}", resource.relative_path);
if resource.source_path != expected_source {
return Err(CompileError::Invalid(format!(
"{}: resource source path does not match `{expected_source}`",
resource.source_path
)));
}
let script = resource.relative_path.starts_with("scripts/");
if resource.executable != script {
return Err(CompileError::Invalid(format!(
"{}: resource executable mode contradicts its category",
resource.source_path
)));
}
if resource.content.len() > MAX_RESOURCE_BYTES {
return Err(CompileError::Invalid(format!(
"{}: skill resource exceeds {MAX_RESOURCE_BYTES} bytes",
resource.source_path
)));
}
resource_bytes = resource_bytes
.checked_add(resource.content.len())
.ok_or_else(|| {
CompileError::Invalid("skill resource byte count overflow".into())
})?;
if resource_bytes > MAX_SKILL_RESOURCE_BYTES {
return Err(CompileError::Invalid(format!(
"{}: skill resources exceed {MAX_SKILL_RESOURCE_BYTES} bytes",
skill.source_path
)));
}
let text = core::str::from_utf8(&resource.content).map_err(|_| {
CompileError::Invalid(format!(
"{}: skill resource must be UTF-8",
resource.source_path
))
})?;
if resource.relative_path.starts_with("references/") && !text.is_empty() {
validate_budget(&resource.source_path, BudgetClass::Reference, text)?;
}
}
}
Ok(())
}
pub(super) fn validate_canonical_dispatch_topology(role: &RoleInput) -> Result<(), CompileError> {
let expected = match role.role.as_str() {
"shepherd" | "planter" => Some(false),
"auditor" | "coder" | "conductor" | "critic" | "discovery" | "engineer" | "worker" => {
Some(true)
}
_ => None,
};
if let Some(expected) = expected
&& role.dispatchable != expected
{
let authority = if expected { "child" } else { "root" };
return Err(CompileError::Invalid(format!(
"canonical role topology requires `{}` to be a {authority} with dispatchable: {expected}",
role.role
)));
}
Ok(())
}
pub(super) fn validate_planter_contract(role: &RoleInput) -> Result<(), CompileError> {
if role.role != "planter" {
return Ok(());
}
let capabilities_match = role.capabilities.len() == PLANTER_CAPABILITIES.len()
&& role
.capabilities
.iter()
.zip(PLANTER_CAPABILITIES)
.all(|(actual, expected)| actual == expected);
if !capabilities_match {
return Err(CompileError::Invalid(
"planter contract requires the exact native capability set".into(),
));
}
if role.dispatchable {
return Err(CompileError::Invalid(
"planter contract requires dispatchable: false".into(),
));
}
if role.write_scope != PLANTER_WRITE_SCOPE {
return Err(CompileError::Invalid(
"planter contract requires the exact mesh.md and seed.md write scope".into(),
));
}
if !role.write_eligible || role.startup_skill != "planting" {
return Err(CompileError::Invalid(
"planter contract requires write eligibility and the planting skill".into(),
));
}
Ok(())
}
pub(super) fn validate_resource_path(resource_path: &str) -> Result<(), CompileError> {
if validate_source_path(resource_path).is_err() {
return Err(CompileError::Invalid(format!(
"invalid skill resource path `{resource_path}`"
)));
}
let mut segments = resource_path.split('/');
let category = segments.next().unwrap_or_default();
let filename = segments.next().unwrap_or_default();
if !matches!(category, "assets" | "references" | "scripts")
|| filename.is_empty()
|| segments.next().is_some()
{
return Err(CompileError::Invalid(format!(
"invalid skill resource path `{resource_path}`"
)));
}
Ok(())
}
pub(super) fn validate_source_path(source_path: &str) -> Result<(), CompileError> {
if source_path.is_empty()
|| source_path.starts_with('/')
|| source_path.contains('\\')
|| source_path
.split('/')
.any(|segment| matches!(segment, "" | "." | ".."))
{
return Err(CompileError::Invalid(
"source path must be a non-empty relative slash path without traversal".into(),
));
}
Ok(())
}
pub(super) fn validate_identifier(kind: &str, value: &str) -> Result<(), CompileError> {
if ["true", "false", "null", "yes", "no", "on", "off"].contains(&value) {
return Err(CompileError::Invalid(format!(
"YAML-reserved {kind} identifier `{value}`"
)));
}
if value.len() > 64
|| !value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
|| !value
.bytes()
.skip(1)
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err(CompileError::Invalid(format!(
"invalid {kind} identifier `{value}`"
)));
}
Ok(())
}
pub(super) fn validate_description(path: &str, description: &str) -> Result<(), CompileError> {
if description.trim().is_empty() {
return Err(CompileError::Invalid(format!(
"{path}: description must not be empty"
)));
}
if description.chars().count() > 500 {
return Err(CompileError::Invalid(format!(
"{path}: description exceeds 500 characters"
)));
}
if description.contains(['\r', '\n']) {
return Err(CompileError::Invalid(format!(
"{path}: description must be one line"
)));
}
Ok(())
}
pub(super) fn validate_tool(tool: &str) -> Result<(), CompileError> {
if tool.is_empty()
|| tool.len() > 64
|| !tool
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(CompileError::Invalid(
"harness tool names must be non-empty ASCII tokens".into(),
));
}
Ok(())
}
pub(super) fn validate_model_token(model: &str) -> Result<(), CompileError> {
if model.is_empty()
|| model.len() > 128
|| model
.bytes()
.any(|byte| byte.is_ascii_control() || matches!(byte, b'"' | b'\\'))
{
return Err(CompileError::Invalid(
"harness model names must be non-empty safe scalar tokens".into(),
));
}
Ok(())
}