From c0f5ba50eddbd7d38651d4b3ea6ba77b8c3f25e9 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 10 Jun 2026 16:07:20 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Add authority boundary check evidence plumbing","authority":"XY-871"} --- .../src/orchestrator/harness_improvement.rs | 108 ++++++ .../tests/operator/status/agent_evidence.rs | 236 ++++++++++--- .../tests/runtime/loop_scenarios.rs | 110 +++++- apps/decodex/src/orchestrator/types.rs | 312 ++++++++++++++---- docs/runbook/lane-control-recovery.md | 5 + docs/spec/agent-evidence.md | 12 + docs/spec/loop-runtime.md | 75 +++++ docs/spec/runtime.md | 25 +- 8 files changed, 762 insertions(+), 121 deletions(-) diff --git a/apps/decodex/src/orchestrator/harness_improvement.rs b/apps/decodex/src/orchestrator/harness_improvement.rs index bdc973691..314b6899b 100644 --- a/apps/decodex/src/orchestrator/harness_improvement.rs +++ b/apps/decodex/src/orchestrator/harness_improvement.rs @@ -75,6 +75,7 @@ struct HarnessOutcomePayload { validation: HarnessValidationOutcome, repair: HarnessRepairOutcome, review: HarnessReviewOutcome, + authority_boundary: HarnessAuthorityBoundaryOutcome, manual_attention: Option, pr_lifecycle: HarnessPrLifecycleOutcome, linear_projection: HarnessLinearProjectionSummary, @@ -161,6 +162,13 @@ struct HarnessReviewOutcome { nonclean_rounds: i64, } +#[derive(Serialize)] +struct HarnessAuthorityBoundaryOutcome { + dispositions: Vec, + failed_check_count: usize, + improvement_signal_count: usize, +} + #[derive(Serialize)] struct HarnessManualAttentionOutcome { reason_code: String, @@ -191,6 +199,9 @@ struct HarnessOutcomeSignals { nonclean_rounds: i64, repair_phase_events: usize, guardrail_reasons: std::collections::BTreeSet, + authority_boundary_dispositions: std::collections::BTreeSet, + authority_boundary_failed_check_count: usize, + authority_boundary_candidates: Vec, } pub(crate) fn record_harness_outcome_for_issue_run( @@ -240,6 +251,7 @@ pub(crate) fn harness_improvement_candidates_from_private_events( if signals.validation_failure_count == 0 && signals.accepted_finding_count == 0 && signals.guardrail_reasons.is_empty() + && signals.authority_boundary_candidates.is_empty() { return Vec::new(); } @@ -378,6 +390,11 @@ fn harness_outcome_payload( rejected_finding_count: signals.rejected_finding_count, nonclean_rounds: signals.nonclean_rounds, }; + let authority_boundary = HarnessAuthorityBoundaryOutcome { + dispositions: signals.authority_boundary_dispositions.iter().cloned().collect(), + failed_check_count: signals.authority_boundary_failed_check_count, + improvement_signal_count: signals.authority_boundary_candidates.len(), + }; let manual_attention = input.error_class.filter(|_| input.outcome == HarnessOutcomeKind::ManualAttention).map( |reason| HarnessManualAttentionOutcome { reason_code: reason.to_owned() }, @@ -411,6 +428,7 @@ fn harness_outcome_payload( validation, repair, review, + authority_boundary, manual_attention, pr_lifecycle, linear_projection, @@ -506,6 +524,7 @@ fn harness_outcome_signals( push_phase_goal_signal(&mut signals, event), "review_checkpoint" => push_review_signal(&mut signals, event.payload()), "loop_guardrail_checkpoint" => push_guardrail_signal(&mut signals, event.payload()), + "authority_boundary_check" => push_authority_boundary_signal(&mut signals, event.payload()), "progress_checkpoint" => push_progress_signal(&mut signals, event.payload()), _ => {}, } @@ -565,6 +584,60 @@ fn push_guardrail_signal(signals: &mut HarnessOutcomeSignals, payload: &Value) { } } +fn push_authority_boundary_signal(signals: &mut HarnessOutcomeSignals, payload: &Value) { + if let Some(disposition) = json_string(payload.get("disposition")) { + signals.authority_boundary_dispositions.insert(disposition.clone()); + + if disposition != "within_authority" { + signals.authority_boundary_failed_check_count += 1; + } + if matches!(disposition.as_str(), "requires_human" | "insufficient_evidence") + && json_array_len(payload.get("improvement_signals")) == 0 + && authority_boundary_final_reason_mentions_underspecified(payload) + { + let target = first_decision_contract_target(payload) + .unwrap_or_else(|| String::from("issue:local-readback")); + + signals.authority_boundary_candidates.push(HarnessImprovementCandidateSummary { + kind: String::from("underspecified_decision_contract"), + reason_code: String::from("authority_underspecified"), + target, + source_event_count: 1, + recommendation: String::from( + "Add explicit authority-envelope fields before retrying autonomous recovery.", + ), + }); + } + } + if let Some(improvement_signals) = payload + .get("improvement_signals") + .and_then(Value::as_array) + { + for signal in improvement_signals { + let Some(kind) = json_string(signal.get("kind")) else { + continue; + }; + let Some(reason_code) = json_string(signal.get("reason_code")) else { + continue; + }; + let Some(target) = json_string(signal.get("target")) else { + continue; + }; + let Some(recommendation) = json_string(signal.get("recommendation")) else { + continue; + }; + + signals.authority_boundary_candidates.push(HarnessImprovementCandidateSummary { + kind, + reason_code, + target, + source_event_count: 1, + recommendation, + }); + } + } +} + fn push_progress_signal(signals: &mut HarnessOutcomeSignals, payload: &Value) { if json_string(payload.get("phase")).is_some_and(|phase| phase.contains("repair")) { signals.repair_phase_events += 1; @@ -730,6 +803,16 @@ fn push_signal_candidates( recommendation, ); } + for candidate in &signals.authority_boundary_candidates { + insert_candidate( + candidates, + &candidate.kind, + &candidate.reason_code, + &candidate.target, + candidate.source_event_count, + &candidate.recommendation, + ); + } if input.error_class == Some("uncovered_direction") || linear_projection.final_error_class.as_deref() == Some("uncovered_direction") @@ -745,6 +828,31 @@ fn push_signal_candidates( } } +fn authority_boundary_final_reason_mentions_underspecified(payload: &Value) -> bool { + let reason = payload + .get("final_disposition") + .and_then(|value| json_string(value.get("reason"))) + .or_else(|| json_string(payload.get("final_disposition_reason"))); + + reason.is_some_and(|reason| { + let reason = reason.to_ascii_lowercase(); + + reason.contains("underspecified") + || reason.contains("missing contract") + || reason.contains("missing authority") + }) +} + +fn first_decision_contract_target(payload: &Value) -> Option { + payload + .get("decision_contract_ids") + .and_then(Value::as_array)? + .iter() + .filter_map(Value::as_str) + .find(|contract_id| !contract_id.is_empty()) + .map(|contract_id| format!("decision_contract:{contract_id}")) +} + fn guardrail_candidate_kind(reason: &str) -> (&'static str, &'static str) { match reason { "dependency_program_stale" => ( diff --git a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs index 3f95a931d..c02a96f38 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs @@ -1,5 +1,7 @@ use orchestrator::HarnessOutcomeKind; use orchestrator::HarnessOutcomeRecordInput; +use orchestrator::AuthorityBoundaryCheckInput; +use orchestrator::AuthorityBoundaryDisposition; use crate::loop_contract::DecisionContract; @@ -247,6 +249,95 @@ fn private_evidence_readback_summarizes_payloads_without_connector() { assert!(!rendered.contains("full command output stays hidden by default")); } +#[test] +fn agent_evidence_authority_boundary_readback_recommends_candidates_without_payload_leakage() { + let (_temp_dir, config, _workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let private_marker = "PRIVATE_AUTHORITY_READBACK_PAYLOAD"; + + state_store + .upsert_worktree( + TEST_SERVICE_ID, + "issue-boundary", + "x/pubfi-pub-111", + ".worktrees/PUB-111", + ) + .expect("worktree should persist"); + state_store + .record_run_attempt("run-boundary", "issue-boundary", 1, "terminal_guarded") + .expect("run should persist"); + + orchestrator::record_authority_boundary_check_private_event( + &state_store, + AuthorityBoundaryCheckInput { + project_id: TEST_SERVICE_ID, + issue_id: "issue-boundary", + issue_identifier: "PUB-111", + run_id: "run-boundary", + attempt_number: 1, + decision_contract_ids: vec!["contract-boundary"], + attempted_recovery_reason: "ambiguous_retained_progress", + changed_surfaces: vec![orchestrator::AuthorityBoundaryChangedSurface { + surface: "validation_review_gate", + change_summary: private_marker, + classification: orchestrator::AuthorityBoundaryDisposition::InsufficientEvidence, + }], + disposition: AuthorityBoundaryDisposition::InsufficientEvidence, + final_disposition_reason: "Authority evidence is underspecified for recovery.", + improvement_signals: vec![ + orchestrator::AuthorityBoundaryImprovementSignal { + kind: "underspecified_decision_contract", + reason_code: "authority_underspecified", + target: "decision_contract:contract-boundary", + recommendation: "Record validation-gate authority before recovery.", + }, + orchestrator::AuthorityBoundaryImprovementSignal { + kind: "missing_issue_template_field", + reason_code: "authority_boundary_template_gap", + target: "issue_template:loop_recovery", + recommendation: "Add changed-surface prompts to the issue template.", + }, + ], + }, + ) + .expect("authority boundary check should persist"); + + let request = EvidenceRequest { + config_path: None, + issue: "PUB-111", + run_id: Some("run-boundary"), + attempt_number: Some(1), + json: true, + include_payload: false, + }; + let readback = orchestrator::build_private_evidence_readback( + &state_store, + &config, + &request, + ) + .expect("authority boundary evidence should read"); + let rendered = orchestrator::render_private_evidence_readback(&readback); + + assert_eq!(readback.event_count, 1); + assert_eq!(readback.latest_event_type.as_deref(), Some("authority_boundary_check")); + assert!(readback.events.iter().all(|event| event.payload.is_none())); + assert!( + readback.improvement_candidates.iter().any(|candidate| { + candidate.kind == "underspecified_decision_contract" + && candidate.reason_code == "authority_underspecified" + && candidate.target == "decision_contract:contract-boundary" + }) + ); + assert!( + readback.improvement_candidates.iter().any(|candidate| { + candidate.kind == "missing_issue_template_field" + && candidate.reason_code == "authority_boundary_template_gap" + }) + ); + assert!(rendered.contains("authority_underspecified")); + assert!(!rendered.contains(private_marker)); +} + #[test] fn private_evidence_readback_reports_missing_events_for_known_run() { let (_temp_dir, config, _workflow) = temp_project_layout(); @@ -338,55 +429,8 @@ fn harness_outcome_records_validation_review_and_repair_signals() { state_store .record_run_attempt("run-harness", "issue-harness", 2, "failed") .expect("run should persist"); - state_store - .append_private_execution_event( - TEST_SERVICE_ID, - "issue-harness", - "run-harness", - 2, - "phase_goal_completed", - serde_json::json!({ - "schema": "decodex.phase_goal_signal/1", - "phase": "repair_validation_failures", - "payload": { - "signal": "validation_fail", - "status": "complete" - } - }), - ) - .expect("phase goal evidence should append"); - state_store - .append_private_execution_event( - TEST_SERVICE_ID, - "issue-harness", - "run-harness", - 2, - "review_checkpoint", - serde_json::json!({ - "phase": "handoff", - "status": "findings", - "head_sha": "abc123", - "nonclean_rounds": 1, - "review": { - "accepted_findings": [{"summary": "cover the missing edge case"}], - "rejected_findings": [] - } - }), - ) - .expect("review evidence should append"); - state_store - .append_private_execution_event( - TEST_SERVICE_ID, - "issue-harness", - "run-harness", - 2, - "progress_checkpoint", - serde_json::json!({ - "phase": "review_repair", - "focus": "repair accepted finding" - }), - ) - .expect("progress evidence should append"); + + record_harness_signal_fixture_events(&state_store); let recorded = orchestrator::record_harness_outcome_for_issue_run( &state_store, @@ -415,6 +459,8 @@ fn harness_outcome_records_validation_review_and_repair_signals() { ); assert_eq!(payload["repair"]["repair_attempt_observed"], true); assert_eq!(payload["review"]["accepted_finding_count"], 1); + assert_eq!(payload["authority_boundary"]["failed_check_count"], 1); + assert_eq!(payload["authority_boundary"]["improvement_signal_count"], 1); assert!( payload["improvement_candidates"] .as_array() @@ -422,6 +468,13 @@ fn harness_outcome_records_validation_review_and_repair_signals() { .iter() .any(|candidate| candidate["reason_code"] == "accepted_review_findings") ); + assert!( + payload["improvement_candidates"] + .as_array() + .expect("candidates should be an array") + .iter() + .any(|candidate| candidate["reason_code"] == "authority_underspecified") + ); let request = EvidenceRequest { config_path: None, @@ -444,9 +497,94 @@ fn harness_outcome_records_validation_review_and_repair_signals() { .iter() .any(|candidate| candidate.reason_code == "accepted_review_findings") ); + assert!( + readback + .improvement_candidates + .iter() + .any(|candidate| candidate.reason_code == "authority_underspecified") + ); assert!(readback.events.iter().all(|event| event.payload.is_none())); } +fn record_harness_signal_fixture_events(state_store: &StateStore) { + state_store + .append_private_execution_event( + TEST_SERVICE_ID, + "issue-harness", + "run-harness", + 2, + "phase_goal_completed", + serde_json::json!({ + "schema": "decodex.phase_goal_signal/1", + "phase": "repair_validation_failures", + "payload": { + "signal": "validation_fail", + "status": "complete" + } + }), + ) + .expect("phase goal evidence should append"); + state_store + .append_private_execution_event( + TEST_SERVICE_ID, + "issue-harness", + "run-harness", + 2, + "review_checkpoint", + serde_json::json!({ + "phase": "handoff", + "status": "findings", + "head_sha": "abc123", + "nonclean_rounds": 1, + "review": { + "accepted_findings": [{"summary": "cover the missing edge case"}], + "rejected_findings": [] + } + }), + ) + .expect("review evidence should append"); + state_store + .append_private_execution_event( + TEST_SERVICE_ID, + "issue-harness", + "run-harness", + 2, + "progress_checkpoint", + serde_json::json!({ + "phase": "review_repair", + "focus": "repair accepted finding" + }), + ) + .expect("progress evidence should append"); + + orchestrator::record_authority_boundary_check_private_event( + state_store, + AuthorityBoundaryCheckInput { + project_id: TEST_SERVICE_ID, + issue_id: "issue-harness", + issue_identifier: "PUB-110", + run_id: "run-harness", + attempt_number: 2, + decision_contract_ids: vec!["contract-harness"], + attempted_recovery_reason: "uncovered_direction", + changed_surfaces: vec![orchestrator::AuthorityBoundaryChangedSurface { + surface: "accepted_behavior", + change_summary: "Public behavior would change.", + classification: orchestrator::AuthorityBoundaryDisposition::RequiresHuman, + }], + disposition: AuthorityBoundaryDisposition::RequiresHuman, + final_disposition_reason: "Accepted behavior needs explicit authority.", + improvement_signals: vec![orchestrator::AuthorityBoundaryImprovementSignal { + kind: "underspecified_decision_contract", + reason_code: "authority_underspecified", + target: "decision_contract:contract-harness", + recommendation: "Record accepted-behavior authority before recovery.", + }], + }, + ) + .expect("authority boundary evidence should append"); +} + #[test] fn harness_eval_fixture_recommends_contract_improvement_without_payload_leakage() { let (_temp_dir, config, _workflow) = temp_project_layout(); diff --git a/apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs b/apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs index ffb1043c7..f978d6315 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/loop_scenarios.rs @@ -19,6 +19,10 @@ use crate::loop_contract::DecisionContract; use crate::loop_contract::DecisionContractStatus; use crate::loop_contract::DecisionPromotion; use crate::loop_contract::DecisionPromotionActorKind; +use crate::orchestrator::AuthorityBoundaryChangedSurface; +use crate::orchestrator::AuthorityBoundaryCheckInput; +use crate::orchestrator::AuthorityBoundaryDisposition; +use crate::orchestrator::AuthorityBoundaryImprovementSignal; use crate::orchestrator; use crate::orchestrator::HarnessOutcomeKind; use crate::orchestrator::HarnessOutcomeRecordInput; @@ -162,6 +166,8 @@ impl LoopScenarioHarness { ) { let private_review_marker = self.record_review_checkpoint_and_assert_repair_then_escalation(); + let private_authority_marker = + self.record_requires_human_authority_boundary(contract.contract_id()); self.record_uncovered_direction_guardrail(); self.state_store @@ -192,7 +198,11 @@ impl LoopScenarioHarness { ) .expect("harness outcome should record"); - loop_scenario_assert_harness_candidates(recorded.payload(), private_review_marker); + loop_scenario_assert_harness_candidates( + recorded.payload(), + private_review_marker, + private_authority_marker, + ); } fn record_review_checkpoint_and_assert_repair_then_escalation(&self) -> &'static str { @@ -298,6 +308,76 @@ impl LoopScenarioHarness { assert_eq!(uncovered_checkpoint.reason(), "uncovered_direction"); assert_eq!(uncovered_checkpoint.consecutive_count(), 1); } + + fn record_requires_human_authority_boundary( + &self, + contract_id: &str, + ) -> &'static str { + let private_authority_marker = "PRIVATE_AUTHORITY_PAYLOAD_SHOULD_NOT_SURFACE"; + let event = orchestrator::record_authority_boundary_check_private_event( + &self.state_store, + AuthorityBoundaryCheckInput { + project_id: "decodex", + issue_id: self.issue_id, + issue_identifier: self.issue_identifier, + run_id: self.run_id, + attempt_number: self.attempt_number, + decision_contract_ids: vec![contract_id], + attempted_recovery_reason: "uncovered_direction", + changed_surfaces: vec![AuthorityBoundaryChangedSurface { + surface: "accepted_behavior", + change_summary: private_authority_marker, + classification: AuthorityBoundaryDisposition::RequiresHuman, + }], + disposition: AuthorityBoundaryDisposition::RequiresHuman, + final_disposition_reason: + "Accepted behavior would change and the authority envelope is underspecified.", + improvement_signals: vec![ + AuthorityBoundaryImprovementSignal { + kind: "underspecified_decision_contract", + reason_code: "authority_underspecified", + target: "decision_contract:research-x-loop-contract", + recommendation: + "Add explicit accepted-behavior authority before autonomous recovery.", + }, + AuthorityBoundaryImprovementSignal { + kind: "missing_issue_template_field", + reason_code: "authority_boundary_template_gap", + target: "issue_template:loop_recovery", + recommendation: + "Require issue briefs to name authority-sensitive changed surfaces.", + }, + AuthorityBoundaryImprovementSignal { + kind: "missing_validator", + reason_code: "authority_boundary_validator_gap", + target: "validator:authority_boundary", + recommendation: + "Add a validator that fails recovery when accepted behavior changes.", + }, + ], + }, + ) + .expect("authority boundary event should persist"); + + assert_eq!(event.event_type(), "authority_boundary_check"); + assert_eq!(event.payload()["schema"], "decodex.authority_boundary_check/1"); + assert_eq!(event.payload()["disposition"], "requires_human"); + assert_eq!(event.payload()["issue"]["identifier"], self.issue_identifier); + assert_eq!(event.payload()["run"]["run_id"], self.run_id); + assert_eq!( + event.payload()["decision_contract_ids"], + serde_json::json!([contract_id]) + ); + assert!( + self.state_store + .list_linear_execution_events("decodex", self.issue_id) + .expect("linear cache should read") + .is_empty(), + "authority boundary checks must stay out of the public Linear mirror" + ); + + private_authority_marker + } } #[test] @@ -551,6 +631,7 @@ fn loop_scenario_repo_gate_failure() -> Report { fn loop_scenario_assert_harness_candidates( payload: &Value, private_review_marker: &str, + private_authority_marker: &str, ) { let candidates = payload["improvement_candidates"] .as_array() @@ -572,10 +653,37 @@ fn loop_scenario_assert_harness_candidates( }), "accepted review findings should recommend a prompt or fixture improvement" ); + assert_eq!(payload["authority_boundary"]["failed_check_count"], 1); + assert_eq!(payload["authority_boundary"]["improvement_signal_count"], 3); + assert!( + candidates.iter().any(|candidate| { + candidate["kind"] == "underspecified_decision_contract" + && candidate["reason_code"] == "authority_underspecified" + }), + "authority underspecification should recommend a contract improvement" + ); + assert!( + candidates.iter().any(|candidate| { + candidate["kind"] == "missing_issue_template_field" + && candidate["reason_code"] == "authority_boundary_template_gap" + }), + "authority gaps should recommend issue-template hardening" + ); + assert!( + candidates.iter().any(|candidate| { + candidate["kind"] == "missing_validator" + && candidate["reason_code"] == "authority_boundary_validator_gap" + }), + "authority gaps should recommend validator hardening" + ); assert!( !candidate_json.contains(private_review_marker), "harness recommendations must summarize private events without leaking raw payloads" ); + assert!( + !candidate_json.contains(private_authority_marker), + "authority-boundary recommendations must not leak raw changed-surface payloads" + ); } fn loop_scenario_research_x_contract() -> DecisionContract { diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 7486e07ac..cb9a3ba4f 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -1,8 +1,15 @@ +use state::PrivateExecutionEvent; + use crate::tracker; type PullRequestReadbackResult = std::result::Result; +#[allow(dead_code)] +const AUTHORITY_BOUNDARY_CHECK_SCHEMA: &str = "decodex.authority_boundary_check/1"; +#[allow(dead_code)] +const AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE: &str = "authority_boundary_check"; + trait PullRequestReviewStateInspector { fn inspect_review_state( &self, @@ -101,6 +108,24 @@ pub(crate) enum RetryKind { Failure, } +/// Final authority disposition for one loop recovery boundary check. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AuthorityBoundaryDisposition { + WithinAuthority, + RequiresHuman, + InsufficientEvidence, +} +impl AuthorityBoundaryDisposition { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::WithinAuthority => "within_authority", + Self::RequiresHuman => "requires_human", + Self::InsufficientEvidence => "insufficient_evidence", + } + } +} + pub(crate) enum RetryDispatchDecision { Blocked { excluded_issue_ids: Vec }, Dispatch(Box), @@ -194,6 +219,111 @@ enum PostReviewLaneStateLoad { ReviewState(PullRequestReviewState), } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LoopGuardrailReason { + ValidationRepeat, + NoEffectiveDiff, + RemainingDeltaUnchanged, + ReviewChurn, + DependencyProgramStale, + UncoveredDirection, + AmbiguousRetainedProgress, +} +impl LoopGuardrailReason { + fn error_class(self) -> &'static str { + match self { + Self::ValidationRepeat => "validation_repeat", + Self::NoEffectiveDiff => "no_effective_diff", + Self::RemainingDeltaUnchanged => "remaining_delta_unchanged", + Self::ReviewChurn => "review_churn", + Self::DependencyProgramStale => "dependency_program_stale", + Self::UncoveredDirection => "uncovered_direction", + Self::AmbiguousRetainedProgress => "ambiguous_retained_progress", + } + } + + fn from_error_class(error_class: &str) -> Option { + match error_class { + "validation_repeat" | "validation_failure_repeated" => Some(Self::ValidationRepeat), + "no_effective_diff" => Some(Self::NoEffectiveDiff), + "remaining_delta_unchanged" => Some(Self::RemainingDeltaUnchanged), + "review_churn" | "review_policy_exhausted" => Some(Self::ReviewChurn), + "dependency_program_stale" | "dependency_blocked" => { + Some(Self::DependencyProgramStale) + }, + "uncovered_direction" | "research_contract_required" => { + Some(Self::UncoveredDirection) + }, + "ambiguous_retained_progress" | "ownership_ambiguous" => { + Some(Self::AmbiguousRetainedProgress) + }, + _ => None, + } + } + + fn terminal_next_action(self, recovery_gate: &str) -> String { + match self { + Self::ValidationRepeat => format!( + "inspect the repeated validation failure, preserved worktree, and prior repair attempts; change repair strategy or route the issue to architecture/research review manually, {recovery_gate}" + ), + Self::NoEffectiveDiff => format!( + "inspect the retained worktree and retry evidence; do not continue automatic repair until a human identifies a concrete next diff or resets the lane, {recovery_gate}" + ), + Self::RemainingDeltaUnchanged => format!( + "inspect the unchanged remaining delta and validation evidence; decide the next bounded repair manually before requeueing, {recovery_gate}" + ), + Self::ReviewChurn => format!( + "inspect the repeated review findings and current head; decide the next repair or architecture review manually before requeueing, {recovery_gate}" + ), + Self::DependencyProgramStale => format!( + "inspect the dependency blocker and Execution Program readiness evidence; refresh dependencies or split/research the program before requeueing, {recovery_gate}" + ), + Self::UncoveredDirection => format!( + "capture the missing direction in a research or decision contract before continuing execution, {recovery_gate}" + ), + Self::AmbiguousRetainedProgress => format!( + "inspect retained partial progress and ownership evidence; choose resume, reset, or manual repair explicitly before clearing the guard, {recovery_gate}" + ), + } + } +} + +/// One surface considered by an authority boundary check. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct AuthorityBoundaryChangedSurface<'a> { + pub(crate) surface: &'a str, + pub(crate) change_summary: &'a str, + pub(crate) classification: AuthorityBoundaryDisposition, +} + +/// Sanitized harness feedback emitted from an authority boundary check. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct AuthorityBoundaryImprovementSignal<'a> { + pub(crate) kind: &'a str, + pub(crate) reason_code: &'a str, + pub(crate) target: &'a str, + pub(crate) recommendation: &'a str, +} + +/// Input for persisting a structured authority boundary check as private evidence. +#[allow(dead_code)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AuthorityBoundaryCheckInput<'a> { + pub(crate) project_id: &'a str, + pub(crate) issue_id: &'a str, + pub(crate) issue_identifier: &'a str, + pub(crate) run_id: &'a str, + pub(crate) attempt_number: i64, + pub(crate) decision_contract_ids: Vec<&'a str>, + pub(crate) attempted_recovery_reason: &'a str, + pub(crate) changed_surfaces: Vec>, + pub(crate) disposition: AuthorityBoundaryDisposition, + pub(crate) final_disposition_reason: &'a str, + pub(crate) improvement_signals: Vec>, +} + /// One bounded run invocation and its optional daemon-planned overrides. pub(crate) struct RunOnceRequest<'a> { pub(crate) config_path: Option<&'a Path>, @@ -646,75 +776,6 @@ impl Display for RetainedPartialProgress { impl Error for RetainedPartialProgress {} -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum LoopGuardrailReason { - ValidationRepeat, - NoEffectiveDiff, - RemainingDeltaUnchanged, - ReviewChurn, - DependencyProgramStale, - UncoveredDirection, - AmbiguousRetainedProgress, -} -impl LoopGuardrailReason { - fn error_class(self) -> &'static str { - match self { - Self::ValidationRepeat => "validation_repeat", - Self::NoEffectiveDiff => "no_effective_diff", - Self::RemainingDeltaUnchanged => "remaining_delta_unchanged", - Self::ReviewChurn => "review_churn", - Self::DependencyProgramStale => "dependency_program_stale", - Self::UncoveredDirection => "uncovered_direction", - Self::AmbiguousRetainedProgress => "ambiguous_retained_progress", - } - } - - fn from_error_class(error_class: &str) -> Option { - match error_class { - "validation_repeat" | "validation_failure_repeated" => Some(Self::ValidationRepeat), - "no_effective_diff" => Some(Self::NoEffectiveDiff), - "remaining_delta_unchanged" => Some(Self::RemainingDeltaUnchanged), - "review_churn" | "review_policy_exhausted" => Some(Self::ReviewChurn), - "dependency_program_stale" | "dependency_blocked" => { - Some(Self::DependencyProgramStale) - }, - "uncovered_direction" | "research_contract_required" => { - Some(Self::UncoveredDirection) - }, - "ambiguous_retained_progress" | "ownership_ambiguous" => { - Some(Self::AmbiguousRetainedProgress) - }, - _ => None, - } - } - - fn terminal_next_action(self, recovery_gate: &str) -> String { - match self { - Self::ValidationRepeat => format!( - "inspect the repeated validation failure, preserved worktree, and prior repair attempts; change repair strategy or route the issue to architecture/research review manually, {recovery_gate}" - ), - Self::NoEffectiveDiff => format!( - "inspect the retained worktree and retry evidence; do not continue automatic repair until a human identifies a concrete next diff or resets the lane, {recovery_gate}" - ), - Self::RemainingDeltaUnchanged => format!( - "inspect the unchanged remaining delta and validation evidence; decide the next bounded repair manually before requeueing, {recovery_gate}" - ), - Self::ReviewChurn => format!( - "inspect the repeated review findings and current head; decide the next repair or architecture review manually before requeueing, {recovery_gate}" - ), - Self::DependencyProgramStale => format!( - "inspect the dependency blocker and Execution Program readiness evidence; refresh dependencies or split/research the program before requeueing, {recovery_gate}" - ), - Self::UncoveredDirection => format!( - "capture the missing direction in a research or decision contract before continuing execution, {recovery_gate}" - ), - Self::AmbiguousRetainedProgress => format!( - "inspect retained partial progress and ownership evidence; choose resume, reset, or manual repair explicitly before clearing the guard, {recovery_gate}" - ), - } - } -} - #[derive(Debug)] struct LoopGuardrailStopRequested { issue_identifier: String, @@ -1806,6 +1867,119 @@ struct PullRequestStatusCheckRollup { state: String, } +#[allow(dead_code)] +pub(crate) fn record_authority_boundary_check_private_event( + state_store: &StateStore, + input: AuthorityBoundaryCheckInput<'_>, +) -> Result { + validate_authority_boundary_check_input(&input)?; + + let changed_surfaces = input + .changed_surfaces + .iter() + .map(|surface| { + json!({ + "surface": surface.surface, + "change_summary": surface.change_summary, + "classification": surface.classification.as_str(), + }) + }) + .collect::>(); + let improvement_signals = input + .improvement_signals + .iter() + .map(|signal| { + json!({ + "kind": signal.kind, + "reason_code": signal.reason_code, + "target": signal.target, + "recommendation": signal.recommendation, + }) + }) + .collect::>(); + let payload = json!({ + "schema": AUTHORITY_BOUNDARY_CHECK_SCHEMA, + "record_version": 1, + "issue": { + "id": input.issue_id, + "identifier": input.issue_identifier, + }, + "run": { + "run_id": input.run_id, + "attempt_number": input.attempt_number, + }, + "decision_contract_ids": input.decision_contract_ids, + "attempted_recovery_reason": input.attempted_recovery_reason, + "changed_surfaces": changed_surfaces, + "disposition": input.disposition.as_str(), + "final_disposition": { + "disposition": input.disposition.as_str(), + "reason": input.final_disposition_reason, + }, + "improvement_signals": improvement_signals, + }); + + state_store.append_private_execution_event( + input.project_id, + input.issue_id, + input.run_id, + input.attempt_number, + AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE, + payload, + ) +} + +fn validate_authority_boundary_check_input( + input: &AuthorityBoundaryCheckInput<'_>, +) -> Result<()> { + authority_boundary_required("authority boundary project_id", input.project_id)?; + authority_boundary_required("authority boundary issue_id", input.issue_id)?; + authority_boundary_required("authority boundary issue_identifier", input.issue_identifier)?; + authority_boundary_required("authority boundary run_id", input.run_id)?; + authority_boundary_required( + "authority boundary attempted_recovery_reason", + input.attempted_recovery_reason, + )?; + authority_boundary_required( + "authority boundary final_disposition_reason", + input.final_disposition_reason, + )?; + + if input.attempt_number < 1 { + eyre::bail!("Authority boundary attempt_number must be positive."); + } + + for contract_id in &input.decision_contract_ids { + authority_boundary_required("authority boundary decision_contract_id", contract_id)?; + } + for surface in &input.changed_surfaces { + authority_boundary_required("authority boundary changed surface", surface.surface)?; + authority_boundary_required( + "authority boundary changed surface summary", + surface.change_summary, + )?; + } + for signal in &input.improvement_signals { + authority_boundary_required("authority boundary improvement kind", signal.kind)?; + authority_boundary_required("authority boundary improvement reason_code", signal.reason_code)?; + authority_boundary_required("authority boundary improvement target", signal.target)?; + authority_boundary_required( + "authority boundary improvement recommendation", + signal.recommendation, + )?; + } + + Ok(()) +} + +fn authority_boundary_required(name: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + eyre::bail!("{name} must not be empty."); + } + + Ok(()) +} + fn classify_pull_request_readback_report(error: &Report) -> PullRequestReadbackRootCause { if report_has_io_error_kind(error, ErrorKind::NotFound) { return PullRequestReadbackRootCause::MissingGithubCli; diff --git a/docs/runbook/lane-control-recovery.md b/docs/runbook/lane-control-recovery.md index 54fa313c2..4ab5a7d53 100644 --- a/docs/runbook/lane-control-recovery.md +++ b/docs/runbook/lane-control-recovery.md @@ -68,6 +68,9 @@ Before mutating anything, confirm: - `active_lease_missing` rejections together with process, protocol, channel, branch, and retained worktree evidence when the lane still appears live - private evidence and public lifecycle signal +- latest `authority_boundary_check` private event when guardrail pressure, broad + steer, hard fallback, ambiguous retained progress, or uncovered direction could + change the accepted authority envelope - PR URL, head branch, and head SHA when the lane has crossed review handoff If these facts do not prove the requested lane, do not steer, interrupt, retry, resume, @@ -89,6 +92,8 @@ or clean labels. | Broad steer materially changes the objective or acceptance contract. | Preserve audit and resolve lifecycle explicitly. | Update and requeue the same issue, create a new issue/lane, or route the owned run to manual attention. | | Operator wants a different issue or replacement task. | Treat as task replacement, not steer. | Stop or pause through supported controls as needed, then create/update/requeue through the supported lifecycle. | | Status or Linear failure summary reports a loop guardrail reason. | Stop automatic recovery and inspect the reason-specific evidence. | Follow the loop guardrail recovery table below before clearing `decodex:needs-attention` or requeueing. | +| Authority Boundary Check reports `within_authority`. | Continue only if lane identity, ownership, and validation evidence still match. | Resume through the supported retained-lane path; keep the boundary-check event as private evidence. | +| Authority Boundary Check reports `requires_human` or `insufficient_evidence`. | Stop automatic recovery. | Keep or apply `decodex:needs-attention`, capture the missing direction or evidence, and continue only after the Decision Contract, issue, policy, or human direction explicitly authorizes the change. | | Evidence is missing, contradictory, or would require guessing whether local work is safe to overwrite. | Stop automatic recovery. | Use manual attention with structured public blockers and keep private evidence local. | ## Loop Guardrail Recovery diff --git a/docs/spec/agent-evidence.md b/docs/spec/agent-evidence.md index 33306f07b..e67df85c0 100644 --- a/docs/spec/agent-evidence.md +++ b/docs/spec/agent-evidence.md @@ -156,6 +156,13 @@ The readback includes: - event count, latest event type, and latest event timestamp - compact event rows with record id, event type, recorded timestamp, and payload summaries +- Authority Boundary Check summaries when `authority_boundary_check` events are + present. Default readback may expose the event type, payload keys, disposition + previews, and sanitized improvement candidates, but not raw changed-surface payloads + unless `--include-payload` is explicitly requested. +- harness improvement candidates derived from `decodex.harness_outcome/1` events or, + when no harness outcome has been recorded yet, directly from private validation, + review, guardrail, and authority-boundary signals - `private_execution_evidence_missing` when the selected run is known but has no private execution events @@ -165,6 +172,11 @@ for full structured local payloads when a repair requires them. This flag still reads from the local runtime store only; it must not mirror payloads into Linear, GitHub, or agent-evidence files. +Authority Boundary Check candidates are advisory harness feedback. They may recommend +Decision Contract, issue-template, validator, prompt, or readiness-model hardening +when recovery evidence shows underspecified authority. They do not change queue +eligibility, accepted Decision Contracts, or project policy by themselves. + ## Event Stream `events.jsonl` uses schema `decodex.agent_evidence_event/1`. diff --git a/docs/spec/loop-runtime.md b/docs/spec/loop-runtime.md index 84e4bd9df..a19c877b9 100644 --- a/docs/spec/loop-runtime.md +++ b/docs/spec/loop-runtime.md @@ -135,6 +135,81 @@ Linear execution-ledger comments, generated issue text, and operator summaries m to or summarize an accepted contract, but they are public/coarse mirrors and must not become the source of truth for private loop state. +## Authority Envelope + +The Authority Envelope is the loop-runtime boundary that decides what an autonomous +recovery attempt may change without asking for human direction. It is derived from the +accepted Decision Contract, project `WORKFLOW.md`, registered project policy, current +issue briefing, and explicit user direction or steering that is still within the same +accepted objective. + +The core rule is: + +> Decodex may autonomously change how engineering is implemented, but it must not +> silently change what was authorized. + +Within authority: + +- internal refactors, schema plumbing, tests, or docs needed to satisfy the same + accepted objective and acceptance criteria +- replacement of one implementation strategy with another when public behavior, + validation strength, project policy, and user direction remain unchanged +- additional private evidence, diagnostics, and harness feedback that do not change + the authorized work +- stricter local validation or review evidence that preserves or narrows the + accepted contract + +Human-required: + +- product goal changes or replacement of the issue objective +- accepted behavior changes, even when the code delta is small +- public API, CLI, configuration, workflow, or compatibility-contract changes not + authorized by the accepted contract +- security, credential, billing, privacy, destructive data-loss, or live-operation + risk that was not already accepted +- validation, review, or repo-gate weakening +- ownership conflicts with another active, retained, review, landing, or cleanup lane +- changes to accepted Decision Contract objectives, non-goals, constraints, + acceptance criteria, validation expectations, or stop conditions + +`insufficient_evidence` is also a stop disposition. Use it when the runtime cannot +prove whether a recovery is inside or outside the envelope from the current Decision +Contract, issue, project policy, lane ownership, and private evidence. + +## Authority Boundary Check + +Before autonomous loop recovery continues a detached or guardrail-pressured lane, the +runtime must record a private Authority Boundary Check when the attempted recovery +could change the Authority Envelope or when evidence is too weak to prove that it does +not. This check is evidence plumbing for downstream recovery workers; this spec does +not implement the full autonomous architecture recovery execution loop. + +The private payload is versioned as `decodex.authority_boundary_check/1` with +`event_type = "authority_boundary_check"` in `private_execution_events`. It records: + +- issue id and issue identifier +- run id and attempt number +- referenced Decision Contract ids when known +- attempted recovery reason, such as `uncovered_direction`, + `ambiguous_retained_progress`, `review_churn`, or `hard_interrupt_fallback` +- changed surfaces, each with a surface kind, compact change summary, and local + classification +- final disposition: `within_authority`, `requires_human`, or `insufficient_evidence` +- final disposition reason +- sanitized harness improvement signals when the check reveals an underspecified + contract field, incomplete issue template, weak prompt, weak validator, or stale + readiness model + +Authority Boundary Checks are local private evidence. Linear, GitHub, generated issue +briefs, and ordinary operator summaries may expose only coarse reason codes or next +actions rendered by allowlisted lifecycle paths. They must not mirror raw changed +surfaces, graph ids, transcript text, or private recovery payloads. + +Harness feedback may recommend Decision Contract, issue-template, validator, prompt, +or readiness-model hardening from boundary-check failures. Those recommendations are +advisory. They do not modify the accepted Decision Contract, queue eligibility, or +project policy by themselves. + ## Promotion Boundary Promotion is the boundary between design and execution authority. diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 5e122178a..4fc4dde00 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -89,9 +89,12 @@ The following facts are local runtime truth and must not be rebuilt from Linear - active run-control channel metadata and local control audit events - protocol events, event counts, event timestamps, and thread/liveness hydration fields - private execution events carrying structured local evidence for an issue/run/attempt +- authority-boundary-check events that classify whether a loop recovery attempt is + inside the accepted Authority Envelope, requires human direction, or lacks enough + evidence to decide - harness-outcome events correlating Decision Contracts, generated issue or node ids, - validation/review/repair/manual-attention/PR lifecycle outcomes, and private - improvement candidates + authority-boundary checks, validation/review/repair/manual-attention/PR lifecycle + outcomes, and private improvement candidates - review-policy checkpoint state: current phase, normalized status, lane head, consecutive non-clean round count, and structured independent-review detail - loop-guardrail checkpoint state: normalized reason, fingerprint, consecutive @@ -138,6 +141,18 @@ This boundary does not create a project-local runtime database contract. The run Contracts. The runtime-facing serialized payload is `decodex.execution_program/1`. It may use DAG semantics, but normal Linear issues remain the executable lanes. +- Authority Envelope: The accepted boundary from the Decision Contract, project + policy, issue briefing, and explicit user direction. Lane recovery may change + implementation details inside this envelope, but product goals, accepted behavior, + public API/config contracts, security/credential/billing/data-loss risk, validation + or review-gate strength, lane ownership, and accepted contract fields require human + authority when they would change. +- Authority Boundary Check: A private execution event with schema + `decodex.authority_boundary_check/1` and event type `authority_boundary_check`. + It records issue id, issue identifier, run id, attempt number, Decision Contract ids, + attempted recovery reason, changed surfaces, final disposition + (`within_authority`, `requires_human`, or `insufficient_evidence`), final reason, and + sanitized harness improvement signals. - Terminal tracker state: A state that should not be auto-started by `decodex`. The default set is `Done`, `Canceled`, and `Duplicate`. ## Eligibility @@ -474,6 +489,12 @@ Review repair churn uses the bounded review policy above and may appear publicly operator should inspect repeated review findings and stop patch-on-patch repair until the next strategy is explicit. +When a stopped lane is eligible for future autonomous recovery, the recovery worker +must consume the latest Authority Boundary Check or record a fresh one before changing +implementation direction. `requires_human` and `insufficient_evidence` dispositions +must route through the human-required path or a later accepted recovery contract; they +must not be treated as retryable repo-gate failures. + If the configured `decodex:needs-attention` label is unavailable on the team and the configured failure state is startable, `decodex` must still block automatic reselection by leaving the issue in a non-startable guard state such as `In Progress`. In that case the failure comment must explain that the label could not be applied and that a human must move the issue back to a startable state manually after repair. Restart recovery must preserve that guard by writing a retained-worktree marker under `.worktrees//.decodex-terminal-guarded` and consulting it before redispatching recovered `In Progress` lanes. Any issue carrying `decodex:needs-attention` is ineligible for another automatic run until a human clears the label and returns the issue to a startable state.