diff --git a/schemas/supervisor-collect-report-v1.schema.json b/schemas/supervisor-collect-report-v1.schema.json index aa7a8709..f225014d 100644 --- a/schemas/supervisor-collect-report-v1.schema.json +++ b/schemas/supervisor-collect-report-v1.schema.json @@ -4085,6 +4085,116 @@ "type": "array", "uniqueItems": true }, + "assignment_shape": { + "additionalProperties": false, + "properties": { + "assignment_input_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "delegation": { + "enum": [ + "terminal", + "delegating" + ] + }, + "effects": { + "enum": [ + "read_only", + "repository_write", + "external_side_effects", + "publication" + ] + }, + "policy_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "profile": { + "additionalProperties": false, + "properties": { + "authority_role": { + "enum": [ + "terminal_leaf", + "delegating", + "acceptance_gate", + "review_auditor", + "audit", + "conflict_resolution", + "failure_classification", + "git_publication", + "unknown_judgment" + ], + "type": "string" + }, + "boundedness": { + "enum": [ + "tightly_bounded", + "bounded", + "cross_cutting", + "unknown" + ], + "type": "string" + }, + "context": { + "enum": [ + "small", + "medium", + "large", + "long", + "unknown" + ], + "type": "string" + }, + "horizon": { + "enum": [ + "short", + "medium", + "long", + "unknown" + ], + "type": "string" + }, + "risk": { + "enum": [ + "low", + "medium", + "high", + "critical", + "unknown" + ], + "type": "string" + }, + "task_class": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "task_class", + "risk", + "boundedness", + "context", + "horizon", + "authority_role" + ], + "type": "object" + }, + "version": { + "const": 1, + "type": "integer" + } + }, + "required": [ + "version", + "policy_sha256", + "assignment_input_sha256", + "profile", + "effects", + "delegation" + ], + "type": "object" + }, "decision_refs": { "description": "Optional citations of resolved DecisionRecord question keys. Non-empty citations load an existing DecisionStore and fail closed on missing, stale, open, or superseded records. Omitted or empty citations do not require a store.", "items": { @@ -10901,6 +11011,116 @@ "type": "array", "uniqueItems": true }, + "assignment_shape": { + "additionalProperties": false, + "properties": { + "assignment_input_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "delegation": { + "enum": [ + "terminal", + "delegating" + ] + }, + "effects": { + "enum": [ + "read_only", + "repository_write", + "external_side_effects", + "publication" + ] + }, + "policy_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "profile": { + "additionalProperties": false, + "properties": { + "authority_role": { + "enum": [ + "terminal_leaf", + "delegating", + "acceptance_gate", + "review_auditor", + "audit", + "conflict_resolution", + "failure_classification", + "git_publication", + "unknown_judgment" + ], + "type": "string" + }, + "boundedness": { + "enum": [ + "tightly_bounded", + "bounded", + "cross_cutting", + "unknown" + ], + "type": "string" + }, + "context": { + "enum": [ + "small", + "medium", + "large", + "long", + "unknown" + ], + "type": "string" + }, + "horizon": { + "enum": [ + "short", + "medium", + "long", + "unknown" + ], + "type": "string" + }, + "risk": { + "enum": [ + "low", + "medium", + "high", + "critical", + "unknown" + ], + "type": "string" + }, + "task_class": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "task_class", + "risk", + "boundedness", + "context", + "horizon", + "authority_role" + ], + "type": "object" + }, + "version": { + "const": 1, + "type": "integer" + } + }, + "required": [ + "version", + "policy_sha256", + "assignment_input_sha256", + "profile", + "effects", + "delegation" + ], + "type": "object" + }, "decision_refs": { "description": "Optional citations of resolved DecisionRecord question keys. Non-empty citations load an existing DecisionStore and fail closed on missing, stale, open, or superseded records. Omitted or empty citations do not require a store.", "items": { diff --git a/schemas/supervisor-final-report-v1.schema.json b/schemas/supervisor-final-report-v1.schema.json index cbadcc6e..87fd9b03 100644 --- a/schemas/supervisor-final-report-v1.schema.json +++ b/schemas/supervisor-final-report-v1.schema.json @@ -3734,6 +3734,116 @@ "type": "array", "uniqueItems": true }, + "assignment_shape": { + "additionalProperties": false, + "properties": { + "assignment_input_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "delegation": { + "enum": [ + "terminal", + "delegating" + ] + }, + "effects": { + "enum": [ + "read_only", + "repository_write", + "external_side_effects", + "publication" + ] + }, + "policy_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "profile": { + "additionalProperties": false, + "properties": { + "authority_role": { + "enum": [ + "terminal_leaf", + "delegating", + "acceptance_gate", + "review_auditor", + "audit", + "conflict_resolution", + "failure_classification", + "git_publication", + "unknown_judgment" + ], + "type": "string" + }, + "boundedness": { + "enum": [ + "tightly_bounded", + "bounded", + "cross_cutting", + "unknown" + ], + "type": "string" + }, + "context": { + "enum": [ + "small", + "medium", + "large", + "long", + "unknown" + ], + "type": "string" + }, + "horizon": { + "enum": [ + "short", + "medium", + "long", + "unknown" + ], + "type": "string" + }, + "risk": { + "enum": [ + "low", + "medium", + "high", + "critical", + "unknown" + ], + "type": "string" + }, + "task_class": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "task_class", + "risk", + "boundedness", + "context", + "horizon", + "authority_role" + ], + "type": "object" + }, + "version": { + "const": 1, + "type": "integer" + } + }, + "required": [ + "version", + "policy_sha256", + "assignment_input_sha256", + "profile", + "effects", + "delegation" + ], + "type": "object" + }, "decision_refs": { "description": "Optional citations of resolved DecisionRecord question keys. Non-empty citations load an existing DecisionStore and fail closed on missing, stale, open, or superseded records. Omitted or empty citations do not require a store.", "items": { @@ -10550,6 +10660,116 @@ "type": "array", "uniqueItems": true }, + "assignment_shape": { + "additionalProperties": false, + "properties": { + "assignment_input_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "delegation": { + "enum": [ + "terminal", + "delegating" + ] + }, + "effects": { + "enum": [ + "read_only", + "repository_write", + "external_side_effects", + "publication" + ] + }, + "policy_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "profile": { + "additionalProperties": false, + "properties": { + "authority_role": { + "enum": [ + "terminal_leaf", + "delegating", + "acceptance_gate", + "review_auditor", + "audit", + "conflict_resolution", + "failure_classification", + "git_publication", + "unknown_judgment" + ], + "type": "string" + }, + "boundedness": { + "enum": [ + "tightly_bounded", + "bounded", + "cross_cutting", + "unknown" + ], + "type": "string" + }, + "context": { + "enum": [ + "small", + "medium", + "large", + "long", + "unknown" + ], + "type": "string" + }, + "horizon": { + "enum": [ + "short", + "medium", + "long", + "unknown" + ], + "type": "string" + }, + "risk": { + "enum": [ + "low", + "medium", + "high", + "critical", + "unknown" + ], + "type": "string" + }, + "task_class": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "task_class", + "risk", + "boundedness", + "context", + "horizon", + "authority_role" + ], + "type": "object" + }, + "version": { + "const": 1, + "type": "integer" + } + }, + "required": [ + "version", + "policy_sha256", + "assignment_input_sha256", + "profile", + "effects", + "delegation" + ], + "type": "object" + }, "decision_refs": { "description": "Optional citations of resolved DecisionRecord question keys. Non-empty citations load an existing DecisionStore and fail closed on missing, stale, open, or superseded records. Omitted or empty citations do not require a store.", "items": { diff --git a/src/autopilot.rs b/src/autopilot.rs index a409b5f0..c571f730 100644 --- a/src/autopilot.rs +++ b/src/autopilot.rs @@ -2971,6 +2971,7 @@ fn supervisor_plan_for_attempt( review_lens_correlation: Default::default(), review_aggregation_policy: profile.review_aggregation_policy, assignments: vec![OrchestratorAssignment { + assignment_shape: None, id: agent_id.to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/autopilot/tests.rs b/src/autopilot/tests.rs index 39197e44..70fa1c82 100644 --- a/src/autopilot/tests.rs +++ b/src/autopilot/tests.rs @@ -191,6 +191,7 @@ fn omitted_profile_preserves_legacy_supervisor_plan_bytes() { review_lens_correlation: Default::default(), review_aggregation_policy: ReviewAggregationPolicy::AllMustAccept, assignments: vec![OrchestratorAssignment { + assignment_shape: None, id: "agent-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -853,6 +854,7 @@ fn licensed_autopilot_supervisor_plan() -> (Value, LicensedBreakageDeclaration, review_lens_correlation: Default::default(), review_aggregation_policy: ReviewAggregationPolicy::AllMustAccept, assignments: vec![OrchestratorAssignment { + assignment_shape: None, id: "child-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/evaluation/experiment.rs b/src/evaluation/experiment.rs index 46a846fe..4eaca1d0 100644 --- a/src/evaluation/experiment.rs +++ b/src/evaluation/experiment.rs @@ -1695,6 +1695,7 @@ fn experiment_plan(manifest: &ExperimentManifest, profile: &EvaluationProfile) - review_lens_correlation: Default::default(), review_aggregation_policy: ReviewAggregationPolicy::AllMustAccept, assignments: vec![OrchestratorAssignment { + assignment_shape: None, id: "child-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/follow_up_queue.rs b/src/follow_up_queue.rs index 1fdb7aa3..ecd0c0d0 100644 --- a/src/follow_up_queue.rs +++ b/src/follow_up_queue.rs @@ -3424,6 +3424,7 @@ mod tests { operator_defaults, }; let assignments = vec![OrchestratorAssignment { + assignment_shape: None, id: assignment_id.clone(), phase: AssignmentPhase::Execution, runtime: None, @@ -3824,6 +3825,7 @@ mod tests { review_lens_correlation: Default::default(), review_aggregation_policy: template.review_aggregation_policy, assignments: vec![OrchestratorAssignment { + assignment_shape: None, id: "child-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise.rs b/src/supervise.rs index 146ec089..6d131d78 100644 --- a/src/supervise.rs +++ b/src/supervise.rs @@ -254,6 +254,10 @@ pub(crate) fn live_quota_concurrency_bound( } mod plan_api; pub use plan_api::*; +mod assignment_shape; +pub use assignment_shape::{ + AssignmentDelegation, AssignmentEffects, AssignmentShapeDeclaration, ValidatedAssignmentShape, +}; mod assignment_executor; pub mod authored_checks; pub(crate) use plan_api::{ @@ -2140,6 +2144,14 @@ pub enum AssignmentPhase { #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct OrchestratorAssignment { pub id: String, + /// Parent-declared fitness, revalidated against exact assignment inputs and + /// frozen policy. Omission remains unproven and grants no eligibility. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "assignment_shape::deserialize_shape" + )] + pub assignment_shape: Option, /// Planning-versus-execution capability selector for implementation roles. /// A Researcher remains read-only in either phase. /// diff --git a/src/supervise/acceptance.rs b/src/supervise/acceptance.rs index 6ca47c4d..96469433 100644 --- a/src/supervise/acceptance.rs +++ b/src/supervise/acceptance.rs @@ -1018,6 +1018,7 @@ pub(super) fn generated_licensed_follow_up_tasks( ); } let follow_up_assignment = OrchestratorAssignment { + assignment_shape: None, id, phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/assignment_execution.rs b/src/supervise/assignment_execution.rs index 218f583a..c1bf3cf4 100644 --- a/src/supervise/assignment_execution.rs +++ b/src/supervise/assignment_execution.rs @@ -1193,6 +1193,7 @@ fn dispatch_isolated_parent_workers_inner( bail!("isolated dispatch grant order differs from the frozen authored turn"); } let subject = OrchestratorAssignment { + assignment_shape: None, id: worker.id.clone(), phase: AssignmentPhase::Execution, runtime: Some(nested_worker_launch_runtime( @@ -9279,6 +9280,7 @@ mod decomposition_tests { .acquire_repository_cleanliness() .expect("source cleanliness capability"); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "leased-source-child".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -9751,6 +9753,7 @@ mod decomposition_tests { commit_fixture_repository(&repo); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "phase-child".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -10178,6 +10181,7 @@ mod decomposition_tests { commit_fixture_repository(&repo); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "phase-child".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -10914,6 +10918,7 @@ mod decomposition_tests { commit_fixture_repository(&repo); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "phase-child".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -11290,6 +11295,7 @@ mod decomposition_tests { fs::write(repo.join("README.md"), "baseline\n").expect("write fixture file"); commit_fixture_repository(&repo); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "phase-child".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -11784,6 +11790,7 @@ mod decomposition_tests { commit_fixture_repository(&repo); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "phase-child".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -12525,6 +12532,7 @@ mod decomposition_tests { commit_fixture_repository(&repo); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "phase-child".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -13385,6 +13393,7 @@ done fn phase_fixture_assignment(id: &str, phase: AssignmentPhase) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase, runtime: None, @@ -13637,6 +13646,7 @@ done fn valid_child_plan_with_nested_worker() -> Result { let assignment = OrchestratorAssignment { + assignment_shape: None, id: "child-with-worker".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -14217,6 +14227,7 @@ done fs::write(&grok, "fixture")?; let (_environment_lock, _environment_guard) = GrokBinaryEnvironmentGuard::install(&grok); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "grok-worker".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -14404,6 +14415,7 @@ done let (_environment_lock, _environment_guard) = GrokBinaryEnvironmentGuard::install_with(&grok, extra_env); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "grok-worker".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -14639,6 +14651,7 @@ done #[test] fn writable_grok_binding_requires_the_explicit_terminal_worker_category() -> Result<()> { let assignment = OrchestratorAssignment { + assignment_shape: None, id: "grok-worker".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -14689,6 +14702,7 @@ done fs::write(&grok, "fixture")?; let (_environment_lock, _environment_guard) = GrokBinaryEnvironmentGuard::install(&grok); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "grok-planner".to_string(), phase: AssignmentPhase::Planning, runtime: None, @@ -14775,6 +14789,7 @@ done #[test] fn assignment_launch_uses_selected_runtime_pair_not_run_global() -> Result<()> { let mut assignment = OrchestratorAssignment { + assignment_shape: None, id: "worker-a".to_string(), phase: AssignmentPhase::Execution, runtime: Some(SupervisorRuntime::Cursor), @@ -14809,6 +14824,7 @@ done #[test] fn admission_consumes_stored_category_and_refuses_luna_coordinator() -> Result<()> { let mut assignment = OrchestratorAssignment { + assignment_shape: None, id: "leaf-worker".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -15146,6 +15162,7 @@ done #[test] fn retry_selected_runtime_supersedes_the_assignment_initial_runtime() { let assignment = OrchestratorAssignment { + assignment_shape: None, id: "worker-retry".to_string(), phase: AssignmentPhase::Execution, runtime: Some(SupervisorRuntime::Codex), @@ -15426,6 +15443,7 @@ done fn weak_mechanical_executor_worker(id: &str) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -16812,6 +16830,7 @@ done commit_fixture_repository(&repo); chmod_control_dirs(&repo)?; let assignment = OrchestratorAssignment { + assignment_shape: None, id: "luna-named-codex".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -17850,6 +17869,7 @@ done let sync_store = open_sync_with_sim_remote(&repo, sim.shared_clone())?; let manager = WorktreeManager::new(&repo); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "remote-bind-agent".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/assignment_execution/messaging_ipc_tests.rs b/src/supervise/assignment_execution/messaging_ipc_tests.rs index 19628d7c..c8a27046 100644 --- a/src/supervise/assignment_execution/messaging_ipc_tests.rs +++ b/src/supervise/assignment_execution/messaging_ipc_tests.rs @@ -394,6 +394,7 @@ fn commit_fixture_repository(path: &Path) { fn messaging_plan_and_metadata() -> (SupervisorPlan, SupervisorPlanMetadata) { let coord = OrchestratorAssignment { + assignment_shape: None, id: COORD_ID.to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -411,6 +412,7 @@ fn messaging_plan_and_metadata() -> (SupervisorPlan, SupervisorPlanMetadata) { decision_refs: Vec::new(), }; let worker = OrchestratorAssignment { + assignment_shape: None, id: WORKER_ID.to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/assignment_shape.rs b/src/supervise/assignment_shape.rs new file mode 100644 index 00000000..a5abdcee --- /dev/null +++ b/src/supervise/assignment_shape.rs @@ -0,0 +1,329 @@ +//! Parent-validated assignment declarations. These describe fitness, never permissions. + +use super::*; +use crate::selection::{ + AssignmentShapeIdentity, AuthorityRole, Boundedness, RiskLevel, TaskProfile, +}; +use serde::{Deserialize, Deserializer, Serialize}; + +/// Declared effect ceiling, including effects beyond repository edits. Declaring +/// an effect does not authorize it; existing admission and publication gates apply. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AssignmentEffects { + ReadOnly, + RepositoryWrite, + ExternalSideEffects, + Publication, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AssignmentDelegation { + Terminal, + Delegating, +} + +/// Optional versioned input from the parent. Deserialization alone does not +/// establish fitness: validate against the current normalized assignment first. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AssignmentShapeDeclaration { + version: u32, + policy_sha256: String, + assignment_input_sha256: String, + profile: TaskProfile, + effects: AssignmentEffects, + delegation: AssignmentDelegation, +} + +/// Constructible only by validating the declaration and its exact bound inputs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedAssignmentShape { + profile: TaskProfile, + identity: AssignmentShapeIdentity, +} + +impl ValidatedAssignmentShape { + pub fn profile(&self) -> &TaskProfile { + &self.profile + } + + pub fn identity(&self) -> &AssignmentShapeIdentity { + &self.identity + } +} + +impl AssignmentShapeDeclaration { + /// Assess one normalized recursive assignment using only declared inputs and + /// the frozen policy. Scores/context remain heuristics, not observations. + pub fn assess( + plan: &SupervisorPlan, + index: usize, + schedule: &[AssignmentScheduleEntry], + frozen: &crate::selection::routing_policy::FrozenRoutingPolicy, + effects: AssignmentEffects, + ) -> Result { + let (assignment_input_sha256, profile, delegation) = + assignment_shape_inputs(plan, index, schedule, frozen, effects)?; + Ok(Self { + version: 1, + policy_sha256: frozen.sha256.clone(), + assignment_input_sha256, + profile, + effects, + delegation, + }) + } + + /// Reject replay drift and dimensions below policy-derived minimums. A + /// parent may conservatively raise a dimension, including to Unknown. + pub fn validate( + &self, + plan: &SupervisorPlan, + index: usize, + schedule: &[AssignmentScheduleEntry], + frozen: &crate::selection::routing_policy::FrozenRoutingPolicy, + ) -> Result { + if self.version != 1 || self.policy_sha256 != frozen.sha256 { + bail!("assignment shape version or frozen policy differs"); + } + let (digest, minimum, delegation) = + assignment_shape_inputs(plan, index, schedule, frozen, self.effects)?; + if self.assignment_input_sha256 != digest || self.delegation != delegation { + bail!("assignment shape task, scope, effects or delegation binding differs"); + } + if self.profile.task_class != minimum.task_class + || self.profile.authority_role != minimum.authority_role + || self.profile.risk < minimum.risk + || self.profile.boundedness < minimum.boundedness + || self.profile.context < minimum.context + || self.profile.horizon < minimum.horizon + { + bail!("assignment shape changes intrinsic authority or understates policy minimums"); + } + let identity = AssignmentShapeIdentity::v1(crate::artifacts::state_auth::sha256_hex( + &serde_json::to_vec(self)?, + )) + .map_err(anyhow::Error::msg)?; + Ok(ValidatedAssignmentShape { + profile: self.profile.clone(), + identity, + }) + } +} + +pub(super) fn deserialize_shape<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + AssignmentShapeDeclaration::deserialize(deserializer).map(Some) +} + +pub(super) fn validate_assignment_shapes( + plan: &SupervisorPlan, + metadata: &SupervisorPlanMetadata, +) -> Result<()> { + for (index, assignment) in plan.assignments.iter().enumerate() { + let Some(declaration) = &assignment.assignment_shape else { + // Legacy omission is unproven; never synthesize a declaration from a role. + continue; + }; + declaration + .validate( + plan, + index, + &metadata.assignment_schedule, + metadata + .resolved_routing_policy + .as_ref() + .context("assignment shape requires its frozen routing policy")?, + ) + .with_context(|| format!("assignment '{}' has an invalid shape", assignment.id))?; + } + Ok(()) +} + +fn assignment_shape_inputs( + plan: &SupervisorPlan, + index: usize, + schedule: &[AssignmentScheduleEntry], + frozen: &crate::selection::routing_policy::FrozenRoutingPolicy, + effects: AssignmentEffects, +) -> Result<(String, TaskProfile, AssignmentDelegation)> { + let assignment = plan + .assignments + .get(index) + .context("assignment shape index is outside the current plan")?; + if schedule.len() != plan.assignments.len() + || schedule + .iter() + .zip(&plan.assignments) + .enumerate() + .any(|(position, (entry, current))| { + entry.assignment_id != current.id || entry.flattened_index != position + }) + { + bail!("assignment shape requires the complete normalized assignment schedule"); + } + let children = schedule + .iter() + .filter(|entry| entry.parent_assignment_id.as_deref() == Some(assignment.id.as_str())) + .collect::>(); + if assignment.role != AgentRole::ChildOrchestrator + && (!children.is_empty() || !assignment.worker_assignments.is_empty()) + { + bail!("terminal assignment shape cannot hide delegated assignments"); + } + let delegation = if assignment.role == AgentRole::ChildOrchestrator + || assignment.effective_role_category() == RoleCategory::DelegatingCoordinator + { + AssignmentDelegation::Delegating + } else { + AssignmentDelegation::Terminal + }; + let writable = + assignment.phase == AssignmentPhase::Execution && assignment.role != AgentRole::Researcher; + if writable && effects == AssignmentEffects::ReadOnly { + bail!("assignment shape cannot understate repository write effects"); + } + let mut bound_assignment = assignment.clone(); + bound_assignment.assignment_shape = None; + bound_assignment.bind_role_category(); + // Serialize repository paths in the same portable form as estimator scope. + // PathBuf retains the supplied '/' spelling until serialization on every host. + canonicalize_shape_paths(&mut bound_assignment.assigned_paths)?; + for worker in &mut bound_assignment.worker_assignments { + canonicalize_shape_paths(&mut worker.assigned_paths)?; + if let Some(path) = &mut worker.report_path { + *path = canonical_shape_path(path)?; + } + } + if let Some(declaration) = &mut bound_assignment.licensed_breakage { + for dependent in &mut declaration.dependents { + canonicalize_shape_paths(&mut dependent.paths)?; + } + } + let mut bound_value = serde_json::to_value(&bound_assignment)?; + // Routing choices are candidate identity, not assignment shape. Automatic + // selection can replace these without changing the declared work. + let fields = bound_value.as_object_mut().context("assignment object")?; + fields.remove("runtime"); + fields.remove("selection_source"); + if let Some(workers) = fields + .get_mut("worker_assignments") + .and_then(Value::as_array_mut) + { + for worker in workers { + let fields = worker.as_object_mut().context("worker object")?; + // Manual designations affect authority even when the category itself + // equals its derived value. Retain them in the assignment binding. + let source = fields.get("selection_source").and_then(Value::as_str); + if source != Some("operator_override") { + fields.remove("selection_source"); + } + } + } + let task = assignment.task.as_deref().unwrap_or(&plan.task); + let bound_input = serde_json::to_vec(&json!({ + "task": task, + "assignment": bound_value, + "schedule": schedule[index], + "children": children, + "effects": effects, + "delegation": delegation, + }))?; + let estimator = super::task_routing::effective_estimator(frozen)?; + if bound_input.len() as u64 > estimator.max_declaration_bytes { + bail!("assignment shape declaration exceeds the frozen policy input bound"); + } + // Count all declared scopes, including nested terminal work, without claiming + // that directory names measure context or that heuristic scores prove safety. + let mut paths = bound_assignment.assigned_paths.clone(); + let mut modules = assignment.semantic_modules.clone(); + let mut symbols = assignment.semantic_symbols.clone(); + let mut text = task.to_string(); + if let Some(notes) = &assignment.notes { + text.push('\n'); + text.push_str(notes); + } + for worker in &bound_assignment.worker_assignments { + paths.extend(worker.assigned_paths.iter().cloned()); + modules.extend(worker.semantic_modules.iter().cloned()); + symbols.extend(worker.semantic_symbols.iter().cloned()); + if let Some(task) = &worker.task { + text.push('\n'); + text.push_str(task); + } + } + paths.sort(); + paths.dedup(); + modules.sort(); + modules.dedup(); + symbols.sort(); + symbols.dedup(); + let proposal = planning::TaskDecompositionProposal { + fragments: vec![planning::TaskSpecFragment { + id: assignment.id.clone(), + // Include the complete bound declaration in the context estimate, + // including environment and authority metadata outside task prose. + text: String::from_utf8(bound_input.clone())?, + }], + assignments: vec![planning::TaskAssignmentProposal { + id: assignment.id.clone(), + task: text.clone(), + fragment_ids: vec![assignment.id.clone()], + assigned_paths: paths, + semantic_symbols: symbols, + semantic_modules: modules, + decision_dependencies: assignment + .decision_refs + .iter() + .map(|reference| reference.question_key().to_string()) + .collect(), + }], + coverage_gaps: Vec::new(), + diagnostics: planning::TaskPathProposalDiagnostics::default(), + disjointness: planning::TaskDisjointnessReport::default(), + }; + let assessment = super::task_routing::assess_task_routing(&text, &proposal, frozen)?; + let base = super::selection_bridge::task_profile_for_role(assignment.role); + let mut profile = super::task_routing::project_task_profile_with_final_features( + &base, + &assessment, + &assessment.features, + frozen, + )?; + if assignment.category_is_operator_override() { + profile.authority_role = AuthorityRole::UnknownJudgment; + } + if effects >= AssignmentEffects::ExternalSideEffects || assignment.licensed_breakage.is_some() { + profile.risk = profile.risk.max(RiskLevel::High); + profile.boundedness = profile.boundedness.max(Boundedness::CrossCutting); + } + if effects == AssignmentEffects::Publication { + profile.risk = profile.risk.max(RiskLevel::Critical); + } + Ok(( + crate::artifacts::state_auth::sha256_hex(&bound_input), + profile, + delegation, + )) +} + +fn canonical_shape_path(path: &Path) -> Result { + super::task_routing::canonical_estimation_scope(path).map(PathBuf::from) +} + +fn canonicalize_shape_paths(paths: &mut [PathBuf]) -> Result<()> { + for path in paths { + *path = canonical_shape_path(path)?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "assignment_shape_tests.rs"] +mod tests; diff --git a/src/supervise/assignment_shape_tests.rs b/src/supervise/assignment_shape_tests.rs new file mode 100644 index 00000000..ee224140 --- /dev/null +++ b/src/supervise/assignment_shape_tests.rs @@ -0,0 +1,556 @@ +use super::*; +use crate::selection::{ContextSize, TaskHorizon}; + +fn frozen_policy() -> Result { + let policy = serde_json::from_str(include_str!("../selection/data/routing-policy.json"))?; + refreeze(policy) +} + +fn refreeze( + policy: crate::selection::routing_policy::LoadedRoutingPolicy, +) -> Result { + policy.validate()?; + Ok(crate::selection::routing_policy::FrozenRoutingPolicy { + sha256: crate::artifacts::state_auth::sha256_hex(&serde_json::to_vec(&policy)?), + policy, + source: "assignment-shape-test".to_string(), + }) +} + +fn recursive_plan() -> Result { + parse_supervisor_plan_with_consultant( + &json!({ + "version": 1, "task": "Update documentation", "max_depth": 3, + "max_child_assignments": 2, + "resolved_routing_policy": frozen_policy()?, + "assignments": [{ + "id": "parent", "phase": "planning", "role": "child_orchestrator", + "assigned_paths": ["README.md"], + "child_assignments": [{ + "id": "leaf", "phase": "execution", "role": "worker", + "role_category": "non_delegating_terminal_worker", + "assigned_paths": ["README.md"], "task": "Fix a documentation typo" + }] + }] + }) + .to_string(), + ) +} + +fn declare(loaded: &mut LoadedSupervisorPlan, index: usize) -> Result { + let declaration = AssignmentShapeDeclaration::assess( + &loaded.plan, + index, + &loaded.plan_metadata.assignment_schedule, + loaded + .plan_metadata + .resolved_routing_policy + .as_ref() + .unwrap(), + if loaded.plan.assignments[index].phase == AssignmentPhase::Planning { + AssignmentEffects::ReadOnly + } else { + AssignmentEffects::RepositoryWrite + }, + )?; + loaded.plan.assignments[index].assignment_shape = Some(declaration.clone()); + Ok(declaration) +} + +fn document(loaded: &LoadedSupervisorPlan) -> Result { + supervisor_plan_value( + &loaded.plan, + &loaded.consultant, + &loaded.assignment_metadata, + &loaded.plan_metadata, + ) +} + +#[test] +fn assignment_shape_replay_rejects_task_and_path_drift() -> Result<()> { + let mut loaded = recursive_plan()?; + let declaration = declare(&mut loaded, 1)?; + let wire = document(&loaded)?; + let restored = parse_supervisor_plan_with_consultant(&wire.to_string())?; + let frozen = loaded + .plan_metadata + .resolved_routing_policy + .as_ref() + .unwrap(); + let validated = declaration.validate( + &restored.plan, + 1, + &restored.plan_metadata.assignment_schedule, + frozen, + )?; + assert_eq!(validated.profile(), &declaration.profile); + assert_eq!(validated.identity().sha256().len(), 64); + assert_eq!(document(&restored)?, wire); + for (pointer, changed) in [ + ("/assignments/1/task", json!("Fix a documentation typo ")), + ("/assignments/1/assigned_paths", json!(["other.md"])), + ("/assignments/1/semantic_modules", json!(["extra"])), + ("/assignments/1/notes", json!("delete obsolete records")), + ("/assignments/1/phase", json!("planning")), + ] { + let mut drift = wire.clone(); + // Notes is optional and may be absent in the canonical document. + if pointer.ends_with("notes") { + drift["assignments"][1]["notes"] = changed; + } else { + *drift.pointer_mut(pointer).unwrap() = changed; + } + assert!( + parse_supervisor_plan_with_consultant(&drift.to_string()).is_err(), + "{pointer}" + ); + } + let mut changed_policy = frozen.policy.clone(); + changed_policy.task_estimation.as_mut().unwrap().risk.base += 1; + let changed_policy = refreeze(changed_policy)?; + assert!(declaration + .validate( + &loaded.plan, + 1, + &loaded.plan_metadata.assignment_schedule, + &changed_policy + ) + .is_err()); + let mut forged_policy = frozen.clone(); + forged_policy + .policy + .task_estimation + .as_mut() + .unwrap() + .risk + .base += 1; + assert!(declaration + .validate( + &loaded.plan, + 1, + &loaded.plan_metadata.assignment_schedule, + &forged_policy + ) + .is_err()); + // Routing a different candidate must not relabel the same assignment. + loaded.plan.assignments[1].runtime = Some(RuntimeId::Grok); + loaded.plan.assignments[1].selection_source = Some(AssignmentSelectionSource::Retry); + assert_eq!( + declaration.validate( + &loaded.plan, + 1, + &loaded.plan_metadata.assignment_schedule, + frozen + )?, + validated + ); + Ok(()) +} + +#[test] +fn assignment_shape_minimums_cover_each_dimension_and_unknown_policy_bands() -> Result<()> { + let mut loaded = recursive_plan()?; + let mut policy = frozen_policy()?.policy; + let estimator = policy.task_estimation.as_mut().unwrap(); + for band in &mut estimator.risk_bands { + band.value = RiskLevel::Critical; + } + for band in &mut estimator.boundedness_bands { + band.value = Boundedness::CrossCutting; + } + for band in &mut estimator.context_bands { + band.value = ContextSize::Long; + } + for band in &mut estimator.horizon_bands { + band.value = TaskHorizon::Long; + } + loaded.plan_metadata.resolved_routing_policy = Some(refreeze(policy.clone())?); + let declaration = declare(&mut loaded, 1)?; + assert_eq!(declaration.profile.risk, RiskLevel::Critical); + assert_eq!(declaration.profile.boundedness, Boundedness::CrossCutting); + assert_eq!(declaration.profile.context, ContextSize::Long); + assert_eq!(declaration.profile.horizon, TaskHorizon::Long); + for (axis, understated) in [ + ("risk", json!("low")), + ("boundedness", json!("tightly_bounded")), + ("context", json!("small")), + ("horizon", json!("short")), + ("authority_role", json!("delegating")), + ("task_class", json!("other")), + ] { + let mut drift = document(&loaded)?; + drift["assignments"][1]["assignment_shape"]["profile"][axis] = understated; + assert!( + parse_supervisor_plan_with_consultant(&drift.to_string()).is_err(), + "{axis}" + ); + } + for band in &mut policy.task_estimation.as_mut().unwrap().risk_bands { + band.value = RiskLevel::Unknown; + } + loaded.plan.assignments[1].assignment_shape = None; + loaded.plan_metadata.resolved_routing_policy = Some(refreeze(policy)?); + let unknown = declare(&mut loaded, 1)?; + assert_eq!(unknown.profile.risk, RiskLevel::Unknown); + let mut drift = document(&loaded)?; + drift["assignments"][1]["assignment_shape"]["profile"]["risk"] = json!("critical"); + assert!(parse_supervisor_plan_with_consultant(&drift.to_string()).is_err()); + Ok(()) +} + +#[test] +fn assignment_shape_effects_delegation_and_authority_cannot_be_lowered() -> Result<()> { + let mut loaded = recursive_plan()?; + let parent = declare(&mut loaded, 0)?; + assert_eq!(parent.delegation, AssignmentDelegation::Delegating); + assert_eq!(parent.profile.authority_role, AuthorityRole::Delegating); + let mut drift = document(&loaded)?; + drift["assignments"][0]["assignment_shape"]["delegation"] = json!("terminal"); + assert!(parse_supervisor_plan_with_consultant(&drift.to_string()).is_err()); + let mut drift = document(&loaded)?; + drift["assignments"][0]["assignment_shape"]["profile"]["authority_role"] = + json!("terminal_leaf"); + assert!(parse_supervisor_plan_with_consultant(&drift.to_string()).is_err()); + let frozen = loaded + .plan_metadata + .resolved_routing_policy + .as_ref() + .unwrap(); + assert!(AssignmentShapeDeclaration::assess( + &loaded.plan, + 1, + &loaded.plan_metadata.assignment_schedule, + frozen, + AssignmentEffects::ReadOnly, + ) + .is_err()); + let external = AssignmentShapeDeclaration::assess( + &loaded.plan, + 1, + &loaded.plan_metadata.assignment_schedule, + frozen, + AssignmentEffects::ExternalSideEffects, + )?; + assert!(external.profile.risk >= RiskLevel::High); + assert!(external.profile.boundedness >= Boundedness::CrossCutting); + loaded.plan.assignments[1].assignment_shape = Some(external); + let mut drift = document(&loaded)?; + drift["assignments"][1]["assignment_shape"]["profile"]["risk"] = json!("low"); + assert!(parse_supervisor_plan_with_consultant(&drift.to_string()).is_err()); + let publication = AssignmentShapeDeclaration::assess( + &loaded.plan, + 1, + &loaded.plan_metadata.assignment_schedule, + frozen, + AssignmentEffects::Publication, + )?; + assert_eq!(publication.profile.risk, RiskLevel::Critical); + assert_eq!( + publication.profile.authority_role, + AuthorityRole::TerminalLeaf + ); + // A designation override cannot masquerade as proven terminal fitness. + loaded.plan.assignments[1].selection_source = Some(AssignmentSelectionSource::OperatorOverride); + let overridden = AssignmentShapeDeclaration::assess( + &loaded.plan, + 1, + &loaded.plan_metadata.assignment_schedule, + frozen, + AssignmentEffects::RepositoryWrite, + )?; + assert_eq!( + overridden.profile.authority_role, + AuthorityRole::UnknownJudgment + ); + Ok(()) +} + +#[test] +fn recursive_assignment_shapes_round_trip_and_legacy_stays_unproven() -> Result<()> { + let mut loaded = recursive_plan()?; + assert!(loaded + .plan + .assignments + .iter() + .all(|assignment| assignment.assignment_shape.is_none())); + let legacy = document(&loaded)?; + declare(&mut loaded, 0)?; + let leaf = declare(&mut loaded, 1)?; + let mut nested = document(&loaded)?; + let leaf_value = nested["assignments"].as_array_mut().unwrap().pop().unwrap(); + nested["assignments"][0]["child_assignments"] = json!([leaf_value]); + nested + .as_object_mut() + .unwrap() + .remove("assignment_schedule"); + let restored = parse_supervisor_plan_with_consultant(&nested.to_string())?; + assert_eq!(restored.plan, loaded.plan); + assert_eq!( + restored.plan.assignments[1].assignment_shape.as_ref(), + Some(&leaf) + ); + for (field, invalid) in [ + ("version", json!(2)), + ("policy_sha256", json!("a".repeat(64))), + ("assignment_input_sha256", json!("not-a-digest")), + ("unknown", json!(true)), + ] { + let mut drift = nested.clone(); + drift["assignments"][0]["child_assignments"][0]["assignment_shape"][field] = invalid; + assert!( + parse_supervisor_plan_with_consultant(&drift.to_string()).is_err(), + "{field}" + ); + } + let mut null = legacy.clone(); + null["assignments"][1]["assignment_shape"] = Value::Null; + assert!(parse_supervisor_plan_with_consultant(&null.to_string()).is_err()); + let mut missing_policy = document(&loaded)?; + missing_policy + .as_object_mut() + .unwrap() + .remove("resolved_routing_policy"); + assert!(parse_supervisor_plan_with_consultant(&missing_policy.to_string()).is_err()); + let mut no_policy = legacy; + no_policy + .as_object_mut() + .unwrap() + .remove("resolved_routing_policy"); + let restored = parse_supervisor_plan_with_consultant(&no_policy.to_string())?; + assert!(restored + .plan + .assignments + .iter() + .all(|assignment| assignment.assignment_shape.is_none())); + Ok(()) +} + +#[test] +fn assignment_shape_bounds_and_fallback_task_are_revalidated() -> Result<()> { + let mut loaded = recursive_plan()?; + loaded.plan.assignments[1].task = None; + declare(&mut loaded, 1)?; + let mut drift = document(&loaded)?; + drift["task"] = json!("A different fallback task"); + assert!(parse_supervisor_plan_with_consultant(&drift.to_string()).is_err()); + loaded.plan.assignments[1].assignment_shape = None; + let mut policy = frozen_policy()?.policy; + policy.task_estimation = None; + loaded.plan_metadata.resolved_routing_policy = Some(refreeze(policy)?); + assert!(declare(&mut loaded, 1).is_err()); + loaded.plan_metadata.resolved_routing_policy = Some(frozen_policy()?); + loaded.plan.assignments[1].notes = Some("x".repeat(262145)); + assert!(declare(&mut loaded, 1).is_err()); + loaded.plan.assignments[1].notes = None; + loaded.plan_metadata.assignment_schedule.pop(); + assert!(declare(&mut loaded, 1).is_err()); + Ok(()) +} + +#[test] +fn assignment_shape_binds_nested_worker_text_scope_and_effects() -> Result<()> { + let mut loaded = recursive_plan()?; + loaded.plan.assignments.truncate(1); + loaded.plan_metadata.assignment_schedule.truncate(1); + loaded.plan.assignments[0].phase = AssignmentPhase::Execution; + loaded.plan.assignments[0].worker_assignments = vec![serde_json::from_value(json!({ + "id": "nested", "role": "worker", "assigned_paths": ["README.md"], + "task": "Fix a documentation typo" + }))?]; + let declaration = declare(&mut loaded, 0)?; + let frozen = loaded + .plan_metadata + .resolved_routing_policy + .as_ref() + .unwrap(); + let schedule = &loaded.plan_metadata.assignment_schedule; + let original = declaration.validate(&loaded.plan, 0, schedule, frozen)?; + let mut expanded = loaded.plan.clone(); + expanded.assignments[0].licensed_breakage = Some(LicensedBreakageDeclaration { + migration_rationale: "x".repeat(30000), + dependents: vec![LicensedBreakageDependentScope { + dependent_id: "client".into(), + paths: vec!["client.rs".into()], + interfaces: Vec::new(), + }], + }); + let expanded = AssignmentShapeDeclaration::assess( + &expanded, + 0, + schedule, + frozen, + AssignmentEffects::RepositoryWrite, + )?; + assert!(expanded.profile.context > original.profile().context); + assert!(expanded.profile.risk >= RiskLevel::High); + for axis in ["task", "path", "effects"] { + let mut changed = loaded.plan.clone(); + let mut shape = declaration.clone(); + match axis { + "task" => { + changed.assignments[0].worker_assignments[0].task = Some("Different work".into()) + } + "path" => { + changed.assignments[0].worker_assignments[0].assigned_paths = + vec!["other.md".into()] + } + "effects" => shape.effects = AssignmentEffects::ExternalSideEffects, + _ => unreachable!(), + } + assert!( + shape.validate(&changed, 0, schedule, frozen).is_err(), + "{axis}" + ); + } + let mut raised = declaration; + raised.profile.risk = RiskLevel::Critical; + let raised = raised.validate(&loaded.plan, 0, schedule, frozen)?; + assert_eq!(raised.profile().risk, RiskLevel::Critical); + assert_ne!(raised.identity(), original.identity()); + Ok(()) +} + +fn nested_path_plan() -> Result { + let mut loaded = recursive_plan()?; + loaded.plan.assignments.truncate(1); + loaded.plan_metadata.assignment_schedule.truncate(1); + let assignment = &mut loaded.plan.assignments[0]; + assignment.phase = AssignmentPhase::Execution; + assignment.assigned_paths = vec!["src/main.rs".into()]; + assignment.worker_assignments = vec![serde_json::from_value(json!({ + "id": "nested", "role": "worker", + "role_category": "non_delegating_terminal_worker", + "assigned_paths": ["src/main.rs"], "report_path": "reports/nested/report.json", + "task": "Fix a documentation typo" + }))?]; + assignment.licensed_breakage = Some(LicensedBreakageDeclaration { + migration_rationale: "Update the dependent interface".into(), + dependents: vec![LicensedBreakageDependentScope { + dependent_id: "client".into(), + paths: vec!["clients/app/main.rs".into()], + interfaces: Vec::new(), + }], + }); + Ok(loaded) +} + +#[test] +fn assignment_shape_nested_paths_use_portable_binding_and_profile() -> Result<()> { + let mut loaded = nested_path_plan()?; + let declaration = declare(&mut loaded, 0)?; + let frozen = loaded + .plan_metadata + .resolved_routing_policy + .as_ref() + .unwrap(); + let schedule = &loaded.plan_metadata.assignment_schedule; + let original = declaration.validate(&loaded.plan, 0, schedule, frozen)?; + // Spell every path explicitly in the reference input, independently of the + // native PathBuf serializer, so Windows must produce the same binding. + let mut expected_assignment = serde_json::to_value(&loaded.plan.assignments[0])?; + expected_assignment + .as_object_mut() + .unwrap() + .remove("assignment_shape"); + expected_assignment["assigned_paths"] = json!(["src/main.rs"]); + expected_assignment["worker_assignments"][0]["assigned_paths"] = json!(["src/main.rs"]); + expected_assignment["worker_assignments"][0]["report_path"] = + json!("reports/nested/report.json"); + expected_assignment["licensed_breakage"]["dependents"][0]["paths"] = + json!(["clients/app/main.rs"]); + let expected_input = serde_json::to_vec(&json!({ + "task": loaded.plan.task, "assignment": expected_assignment, + "schedule": schedule[0], "children": [], + "effects": "repository_write", "delegation": "delegating" + }))?; + assert_eq!( + declaration.assignment_input_sha256, + crate::artifacts::state_auth::sha256_hex(&expected_input) + ); + for axis in 0..=4 { + let mut changed = loaded.plan.clone(); + let assignment = &mut changed.assignments[0]; + if axis == 0 || axis == 4 { + assignment.assigned_paths = vec![r"src\main.rs".into()]; + } + if axis == 1 || axis == 4 { + assignment.worker_assignments[0].assigned_paths = vec![r"src\main.rs".into()]; + } + if axis == 2 || axis == 4 { + assignment.worker_assignments[0].report_path = + Some(r"reports\nested\report.json".into()); + } + if axis == 3 || axis == 4 { + assignment.licensed_breakage.as_mut().unwrap().dependents[0].paths = + vec![r"clients\app\main.rs".into()]; + } + assert_eq!( + AssignmentShapeDeclaration::assess( + &changed, + 0, + schedule, + frozen, + AssignmentEffects::RepositoryWrite + )?, + declaration, + "path field {axis}" + ); + assert_eq!( + declaration.validate(&changed, 0, schedule, frozen)?, + original + ); + } + Ok(()) +} + +#[test] +fn assignment_shape_binds_nested_operator_designation_but_not_retry_sources() -> Result<()> { + let mut loaded = nested_path_plan()?; + let declaration = declare(&mut loaded, 0)?; + let frozen = loaded + .plan_metadata + .resolved_routing_policy + .as_ref() + .unwrap(); + let schedule = &loaded.plan_metadata.assignment_schedule; + let original = declaration.validate(&loaded.plan, 0, schedule, frozen)?; + for source in [ + AssignmentSelectionSource::Automatic, + AssignmentSelectionSource::Retry, + AssignmentSelectionSource::PlanRoleModels, + ] { + let mut changed = loaded.plan.clone(); + changed.assignments[0].runtime = Some(RuntimeId::Grok); + changed.assignments[0].worker_assignments[0].selection_source = Some(source); + assert_eq!( + declaration.validate(&changed, 0, schedule, frozen)?, + original + ); + } + let mut changed = loaded.plan.clone(); + let worker = &mut changed.assignments[0].worker_assignments[0]; + assert_eq!(worker.role_category, Some(worker.role.authority_category())); + worker.selection_source = Some(AssignmentSelectionSource::OperatorOverride); + assert!(declaration.validate(&changed, 0, schedule, frozen).is_err()); + let designated = AssignmentShapeDeclaration::assess( + &changed, + 0, + schedule, + frozen, + AssignmentEffects::RepositoryWrite, + )?; + assert_ne!( + designated.assignment_input_sha256, + declaration.assignment_input_sha256 + ); + assert_ne!( + designated + .validate(&changed, 0, schedule, frozen)? + .identity(), + original.identity() + ); + changed.assignments[0].worker_assignments[0].selection_source = + Some(AssignmentSelectionSource::Retry); + assert!(designated.validate(&changed, 0, schedule, frozen).is_err()); + Ok(()) +} diff --git a/src/supervise/nested_worker_executor.rs b/src/supervise/nested_worker_executor.rs index f3fd6779..3ad4c838 100644 --- a/src/supervise/nested_worker_executor.rs +++ b/src/supervise/nested_worker_executor.rs @@ -122,6 +122,7 @@ fn terminal_subject( // This projection is for existing terminal prompt/model/journal helpers only. // It must never be used to acquire or identify a worktree or a path claim. Ok(OrchestratorAssignment { + assignment_shape: None, id: worker.id.clone(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/plan_api.rs b/src/supervise/plan_api.rs index e93962e0..f6184c8b 100644 --- a/src/supervise/plan_api.rs +++ b/src/supervise/plan_api.rs @@ -560,6 +560,7 @@ fn supervisor_plan_and_consultant_from_goal_spec_proposal( if !direct_worker { let planning_index = assignments.len(); assignments.push(OrchestratorAssignment { + assignment_shape: None, id: planning_id.clone(), phase: AssignmentPhase::Planning, runtime: None, @@ -593,6 +594,7 @@ fn supervisor_plan_and_consultant_from_goal_spec_proposal( .insert(assignment.id.clone(), assignment.fragment_ids.clone()); let execution_index = assignments.len(); assignments.push(OrchestratorAssignment { + assignment_shape: None, id: assignment.id.clone(), phase: AssignmentPhase::Execution, runtime: None, @@ -746,6 +748,7 @@ fn lower_coordinated_execution_proposal( }); } let parent = OrchestratorAssignment { + assignment_shape: None, id:parent_id.clone(), phase:AssignmentPhase::Execution, runtime:None, role:AgentRole::ChildOrchestrator, role_category:Some(RoleCategory::DelegatingCoordinator), selection_source:None, assigned_paths:paths.into_iter().collect(), @@ -2846,6 +2849,7 @@ fn lower_provider_assignment_tree( let flattened_index = assignments.len(); assignments.push(OrchestratorAssignment { + assignment_shape: None, id: node.id.clone(), phase: if is_leaf { AssignmentPhase::Execution @@ -4008,6 +4012,7 @@ fn validate_task_routing_assessment_binding( plan: &SupervisorPlan, metadata: &SupervisorPlanMetadata, ) -> Result<()> { + assignment_shape::validate_assignment_shapes(plan, metadata)?; let Some(assessment) = &metadata.task_routing_assessment else { return Ok(()); }; @@ -8116,6 +8121,7 @@ mod generated_design_decision_lifecycle_tests { task: Option<&str>, ) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase, runtime: None, diff --git a/src/supervise/plan_validation.rs b/src/supervise/plan_validation.rs index 19c2f45b..ec768a07 100644 --- a/src/supervise/plan_validation.rs +++ b/src/supervise/plan_validation.rs @@ -505,6 +505,7 @@ pub(super) fn validate_supervisor_plan_in_repo( .collect(); validate_assignment_decision_refs_for_repo(&plan.assignments, repo)?; + assignment_shape::validate_assignment_shapes(&plan, &metadata)?; Ok((plan, metadata)) } @@ -1487,6 +1488,7 @@ mod plan_validation_decision_ref_tests { fn assignment_with_refs(refs: Vec) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: "child-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/prompts.rs b/src/supervise/prompts.rs index f2d7b3a8..4e1282a5 100644 --- a/src/supervise/prompts.rs +++ b/src/supervise/prompts.rs @@ -2145,6 +2145,7 @@ mod regression_tests { report_path: None, }; let assignment = OrchestratorAssignment { + assignment_shape: None, id: child_id.to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -2311,6 +2312,7 @@ mod regression_tests { .into_iter() .collect(); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "child-runtime".to_string(), phase, runtime: Some(child_runtime), @@ -2415,6 +2417,7 @@ mod regression_tests { #[test] fn worker_embedding_multiplier_uses_all_worker_roles_in_the_run() { let assignment = |id: &str, worker_count: usize| OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -2590,6 +2593,7 @@ mod regression_tests { report_path: None, }; let assignment = OrchestratorAssignment { + assignment_shape: None, id: "child-lite".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -2679,6 +2683,7 @@ mod regression_tests { #[test] fn judgment_and_auditor_prompts_never_receive_the_lite_profile() { let assignment = OrchestratorAssignment { + assignment_shape: None, id: "child-judgment".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -3064,6 +3069,7 @@ mod regression_tests { ]) .expect("review-lens fixture capability policy"); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "child-decorrelated".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -3345,6 +3351,7 @@ mod regression_tests { #[test] fn review_lens_prompt_accepts_bounded_full_transcript_above_fixture_ceiling() -> Result<()> { let assignment = OrchestratorAssignment { + assignment_shape: None, id: "child-bounded-lens".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -3441,6 +3448,7 @@ mod regression_tests { report_path: None, }; let assignment = OrchestratorAssignment { + assignment_shape: None, id: "parent-yield".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/scheduler.rs b/src/supervise/scheduler.rs index 2013b937..ae729a29 100644 --- a/src/supervise/scheduler.rs +++ b/src/supervise/scheduler.rs @@ -8248,6 +8248,7 @@ mod selection_policy_tests { fn terminal_worker_assignment(id: &str) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -8622,6 +8623,7 @@ mod selection_policy_tests { let mut plan = test_plan(); plan.review_lenses = single_parent_acceptance_review_lenses(); let assignment = OrchestratorAssignment { + assignment_shape: None, id: "active-effort-assignment".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -9446,6 +9448,7 @@ mod selection_policy_tests { let (temporary, repo) = initialized_repository(); let mut plan = test_plan(); plan.assignments = vec![OrchestratorAssignment { + assignment_shape: None, id: "assignment-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -9514,6 +9517,7 @@ mod selection_policy_tests { let (temporary, repo) = initialized_repository(); let mut plan = test_plan(); plan.assignments = vec![OrchestratorAssignment { + assignment_shape: None, id: "assignment-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/scheduler/decomposition_tests.rs b/src/supervise/scheduler/decomposition_tests.rs index d17ec23f..893ff952 100644 --- a/src/supervise/scheduler/decomposition_tests.rs +++ b/src/supervise/scheduler/decomposition_tests.rs @@ -9,6 +9,7 @@ static TEST_RUNTIME_MODEL_CATALOG: RuntimeModelCatalog = fn test_assignment(id: &str, path: &str) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -77,6 +78,7 @@ fn insert_mechanical_metadata(metadata: &mut AssignmentMetadata, assignment_id: fn direct_mechanical_assignment(id: &str) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/scheduler/preclaim.rs b/src/supervise/scheduler/preclaim.rs index a1e915cf..1a3940d0 100644 --- a/src/supervise/scheduler/preclaim.rs +++ b/src/supervise/scheduler/preclaim.rs @@ -2182,6 +2182,7 @@ mod tests { fn assignment() -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: "child-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/schema_artifacts.rs b/src/supervise/schema_artifacts.rs index 9d87d390..70852ee5 100644 --- a/src/supervise/schema_artifacts.rs +++ b/src/supervise/schema_artifacts.rs @@ -249,6 +249,7 @@ mod parent_initial_yield_schema_tests { fn delegating_parent(worker_ids: &[&str]) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: "parent-yield".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -2809,6 +2810,7 @@ fn orchestrator_assignment_schema_value() -> serde_json::Value { "semantic_symbols": string_array_schema_value(), "semantic_modules": string_array_schema_value(), "task": {"type": "string"}, + "assignment_shape": assignment_shape_schema_value(), "worker_assignments": {"type": "array", "items": worker_assignment_schema_value()}, "environment_requirements": {"type": "array", "items": environment_requirement_schema_value()}, "licensed_breakage": licensed_breakage_declaration_schema_value(), @@ -2830,6 +2832,28 @@ fn orchestrator_assignment_schema_value() -> serde_json::Value { }) } +fn assignment_shape_schema_value() -> serde_json::Value { + let event = crate::selection::selection_event_schema_value(); + let input = &event["properties"]["provenance"]["properties"]["normalized_input"]; + // Declarations, like historical task profiles, must preserve Unknown axes. + let profile = input["properties"]["outcomes"]["items"]["properties"]["task"].clone(); + json!({ + "type": "object", + "additionalProperties": false, + "required": [ + "version", "policy_sha256", "assignment_input_sha256", "profile", "effects", "delegation" + ], + "properties": { + "version": {"type": "integer", "const": 1}, + "policy_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "assignment_input_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "profile": profile, + "effects": {"enum": ["read_only", "repository_write", "external_side_effects", "publication"]}, + "delegation": {"enum": ["terminal", "delegating"]} + } + }) +} + fn assignment_schedule_entry_schema_value() -> serde_json::Value { json!({ "type": "object", @@ -4726,6 +4750,81 @@ pub(super) fn write_private_prompt( mod selection_schema_tests { use super::*; + #[test] + fn assignment_shape_schema_is_optional_closed_and_versioned() -> Result<()> { + let schema = orchestrator_assignment_schema_value(); + let mut assignment = json!({ + "id": "leaf", "phase": "execution", "role": "worker", + "assigned_paths": ["README.md"], "semantic_symbols": [], "semantic_modules": [], + "worker_assignments": [], "environment_requirements": [] + }); + let schema_id = "https://example.invalid/assignment-shape"; + let mut compiler = boon::Compiler::new(); + compiler.set_default_draft(boon::Draft::V2020_12); + compiler + .add_resource(schema_id, schema) + .expect("register assignment schema"); + let mut schemas = boon::Schemas::new(); + let index = compiler + .compile(schema_id, &mut schemas) + .expect("compile assignment schema"); + assert!(schemas.validate(&assignment, index).is_ok()); + let shape = json!({ + "version": 1, "policy_sha256": "a".repeat(64), + "assignment_input_sha256": "b".repeat(64), + "profile": { + "task_class": "localized_code_change", "risk": "unknown", + "boundedness": "unknown", "context": "unknown", "horizon": "unknown", + "authority_role": "terminal_leaf" + }, + "effects": "repository_write", "delegation": "terminal" + }); + assignment["assignment_shape"] = shape.clone(); + assert!(schemas.validate(&assignment, index).is_ok()); + let typed: OrchestratorAssignment = serde_json::from_value(assignment.clone())?; + assert_eq!(serde_json::to_value(typed.assignment_shape)?, shape); + for (field, invalid) in [ + ("version", json!(2)), + ("policy_sha256", json!("invalid")), + ("effects", json!("none")), + ("delegation", json!("none")), + ("extra", json!(true)), + ] { + let mut changed = assignment.clone(); + changed["assignment_shape"][field] = invalid; + assert!(schemas.validate(&changed, index).is_err(), "{field}"); + } + for field in shape.as_object().unwrap().keys() { + let mut changed = assignment.clone(); + changed["assignment_shape"] + .as_object_mut() + .unwrap() + .remove(field); + assert!( + schemas.validate(&changed, index).is_err(), + "missing {field}" + ); + } + assignment["assignment_shape"] = Value::Null; + assert!(schemas.validate(&assignment, index).is_err()); + for published in [ + supervisor_final_report_schema_value(), + supervisor_collect_report_schema_value(), + serde_json::from_str(include_str!( + "../../schemas/supervisor-final-report-v1.schema.json" + ))?, + serde_json::from_str(include_str!( + "../../schemas/supervisor-collect-report-v1.schema.json" + ))?, + ] { + let carried = &published["properties"]["generated_follow_up_tasks"]["items"] + ["properties"]["supervisor_plan"]["properties"]["assignments"]["items"] + ["properties"]["assignment_shape"]; + assert_eq!(*carried, assignment_shape_schema_value()); + } + Ok(()) + } + fn open_object_schema_paths(value: &serde_json::Value) -> Vec { fn walk(value: &serde_json::Value, path: &str, open: &mut Vec) { match value { @@ -4791,6 +4890,7 @@ mod selection_schema_tests { }], }; let assignment = OrchestratorAssignment { + assignment_shape: None, id: "source-child".into(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/selection_bridge.rs b/src/supervise/selection_bridge.rs index 27ee7327..62542f09 100644 --- a/src/supervise/selection_bridge.rs +++ b/src/supervise/selection_bridge.rs @@ -3578,7 +3578,7 @@ pub(super) fn runtime_catalog_from_priors( }) } -fn task_profile_for_role(role: AgentRole) -> TaskProfile { +pub(super) fn task_profile_for_role(role: AgentRole) -> TaskProfile { match role { AgentRole::Researcher => TaskProfile { task_class: JUDGMENT_SELECTION_TASK_CLASS.to_string(), @@ -6358,6 +6358,7 @@ pub(super) mod tests { fn child_assignment(id: &str) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -8091,6 +8092,7 @@ pub(super) mod tests { fn worker_assignment() -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: "worker-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/task_routing.rs b/src/supervise/task_routing.rs index a0342320..81ed4ecd 100644 --- a/src/supervise/task_routing.rs +++ b/src/supervise/task_routing.rs @@ -106,7 +106,7 @@ impl TaskRoutingAssessment { } } -fn effective_estimator(frozen: &FrozenRoutingPolicy) -> Result<&TaskEstimationPolicy> { +pub(super) fn effective_estimator(frozen: &FrozenRoutingPolicy) -> Result<&TaskEstimationPolicy> { frozen.policy.validate()?; let digest = crate::artifacts::state_auth::sha256_hex(&serde_json::to_vec(&frozen.policy)?); if frozen.sha256 != digest { @@ -321,7 +321,7 @@ pub fn assess_task_routing( }) } -fn canonical_estimation_scope(path: &std::path::Path) -> Result { +pub(super) fn canonical_estimation_scope(path: &std::path::Path) -> Result { let normalized = normalize_repo_relative_path(path)?; Ok(normalized .to_str() diff --git a/src/supervise/tests/acceptance_integrity.rs b/src/supervise/tests/acceptance_integrity.rs index 7c9de197..d1c83e4b 100644 --- a/src/supervise/tests/acceptance_integrity.rs +++ b/src/supervise/tests/acceptance_integrity.rs @@ -3355,6 +3355,7 @@ fn supervisor_required_optional_and_vector_paths_share_reversible_serialization( review_lens_correlation: Default::default(), review_aggregation_policy: ReviewAggregationPolicy::AllMustAccept, assignments: vec![OrchestratorAssignment { + assignment_shape: None, id: "child-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/tests/licensed_breakage.rs b/src/supervise/tests/licensed_breakage.rs index 197b85cb..adcd627f 100644 --- a/src/supervise/tests/licensed_breakage.rs +++ b/src/supervise/tests/licensed_breakage.rs @@ -279,6 +279,7 @@ fn dual_dependent_failure_child(assignment: &OrchestratorAssignment) -> Orchestr fn licensed_follow_up_assignment_for_ordinal(ordinal: usize) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: format!("child-a-licensed-update-{ordinal:02}"), phase: AssignmentPhase::Execution, runtime: None, @@ -427,6 +428,7 @@ fn licensed_auditor_report( fn licensed_follow_up_assignment() -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: "child-a-licensed-update-01".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/tests/mod.rs b/src/supervise/tests/mod.rs index dc685048..b38a9387 100644 --- a/src/supervise/tests/mod.rs +++ b/src/supervise/tests/mod.rs @@ -329,6 +329,7 @@ fn run_injected_git(repo: &Path, args: &[&str]) { fn injected_assignment(with_worker: bool) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: "child-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, @@ -363,6 +364,7 @@ fn injected_assignment(with_worker: bool) -> OrchestratorAssignment { fn injected_named_assignment(id: &str, path: &str) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/tests/prompts_gates.rs b/src/supervise/tests/prompts_gates.rs index c76b1771..42c60bf7 100644 --- a/src/supervise/tests/prompts_gates.rs +++ b/src/supervise/tests/prompts_gates.rs @@ -406,6 +406,7 @@ fn assert_native_edit_journal_reporting_contract(prompt: &str) { fn injected_direct_worker_assignment() -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: "direct-worker-a".to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/tests/run_artifacts.rs b/src/supervise/tests/run_artifacts.rs index fa499b9f..72159bfd 100644 --- a/src/supervise/tests/run_artifacts.rs +++ b/src/supervise/tests/run_artifacts.rs @@ -4384,6 +4384,7 @@ fn unverified_child_attempt_launches_neither_retry_nor_parent_auditor() { review_lens_correlation: Default::default(), review_aggregation_policy: ReviewAggregationPolicy::AllMustAccept, assignments: vec![OrchestratorAssignment { + assignment_shape: None, id: assignment_id.to_string(), phase: AssignmentPhase::Execution, runtime: None, diff --git a/src/supervise/worktree_controls.rs b/src/supervise/worktree_controls.rs index 7934e0a4..70843a4f 100644 --- a/src/supervise/worktree_controls.rs +++ b/src/supervise/worktree_controls.rs @@ -382,6 +382,7 @@ mod tests { fn review_assignment(id: &str, task: String) -> OrchestratorAssignment { OrchestratorAssignment { + assignment_shape: None, id: id.to_string(), phase: AssignmentPhase::Execution, runtime: None,