From 5b073144f7e062c39f7eb617e3e7c8303905161f Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 10 Jun 2026 16:51:16 +0800 Subject: [PATCH 1/2] {"schema":"decodex/commit/1","summary":"add durable authority decision requests","authority":"XY-877"} --- apps/decodex/src/agent/tracker_tool_bridge.rs | 29 ++ .../tests/mutation/dispatch.rs | 112 +++++ .../src/agent/tracker_tool_bridge/tools.rs | 386 +++++++++++++++++- .../src/orchestrator/agent_evidence.rs | 81 ++++ apps/decodex/src/orchestrator/status.rs | 86 ++++ apps/decodex/src/orchestrator/tests.rs | 2 +- .../tests/operator/status/agent_evidence.rs | 98 ++++- .../tests/operator/status/queue.rs | 116 ++++++ apps/decodex/src/orchestrator/types.rs | 154 ++++++- docs/reference/operator-control-plane.md | 10 +- docs/runbook/lane-control-recovery.md | 13 +- docs/spec/agent-evidence.md | 14 +- docs/spec/loop-runtime.md | 25 ++ docs/spec/runtime.md | 13 + docs/spec/tracker-tools.md | 8 + 15 files changed, 1114 insertions(+), 33 deletions(-) diff --git a/apps/decodex/src/agent/tracker_tool_bridge.rs b/apps/decodex/src/agent/tracker_tool_bridge.rs index e8020d7f4..b150905a4 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge.rs @@ -716,6 +716,35 @@ struct CommentArgs { failed_command: Option, raw_error: Option, summary: Option, + decision_request: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthorityDecisionRequestArgs { + boundary_check_id: i64, + decision_request_id: String, + reason_code: String, + boundary_type: String, + proposed_change: String, + why_exceeds_authority: String, + #[serde(default)] + options: Vec, + recommendation: String, + resume_condition: String, + #[serde(default)] + retained_worktree_evidence: Vec, + #[serde(default)] + retained_diff_evidence: Vec, + #[serde(default)] + recovery_attempt_context: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthorityDecisionOptionArgs { + label: String, + description: String, } #[derive(Debug, Deserialize)] diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs index 9c42c42fb..7f33a0365 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs @@ -2,6 +2,9 @@ use records::CLOSEOUT_RECORD_TYPE; use records::CloseoutRecord; use crate::tracker::{self, public_text}; +use crate::orchestrator; +use crate::orchestrator::AuthorityBoundaryCheckInput; +use crate::orchestrator::AuthorityBoundaryDisposition; #[test] fn closeout_apply_validates_merged_pr_and_completed_issue_state() { @@ -802,6 +805,115 @@ fn accepts_manual_attention_public_summary_kind() { ); } +#[test] +fn accepts_authority_boundary_decision_request_without_public_private_evidence_leakage() { + let issue = sample_issue(); + let tracker = tracker_with_current_issue_snapshot(&issue); + let workflow = sample_workflow(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let private_diff_evidence = "private diff path /Users/example/decodex/.worktrees/DEC-1"; + let review_context = sample_review_context(); + let boundary_event = orchestrator::record_authority_boundary_check_private_event( + &state_store, + AuthorityBoundaryCheckInput { + project_id: &review_context.service_id, + issue_id: &issue.id, + issue_identifier: &issue.identifier, + run_id: &review_context.run_id, + attempt_number: review_context.attempt_number, + decision_contract_ids: vec!["contract-dec-1"], + attempted_recovery_reason: "uncovered_direction", + changed_surfaces: vec![crate::orchestrator::AuthorityBoundaryChangedSurface { + surface: "accepted_behavior", + change_summary: "Public CLI behavior would change.", + classification: crate::orchestrator::AuthorityBoundaryDisposition::RequiresHuman, + }], + disposition: AuthorityBoundaryDisposition::RequiresHuman, + final_disposition_reason: "Accepted behavior needs explicit authority.", + improvement_signals: Vec::new(), + }, + ) + .expect("authority boundary check should persist"); + let bridge = TrackerToolBridge::with_run_context_and_state_store( + &tracker, + &issue, + &workflow, + review_context, + &state_store, + ); + let label_response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_LABEL_ADD_TOOL_NAME, + serde_json::json!({ "label": "decodex:needs-attention" }), + ); + let mut args = manual_attention_comment_args(); + + args["error_class"] = serde_json::json!("contract_boundary_required"); + args["next_action"] = serde_json::json!( + "accept, reject, or revise decision request `dr-dec-1-2`, then clear needs-attention and requeue through Decodex" + ); + args["decision_request"] = serde_json::json!({ + "boundary_check_id": boundary_event.record_id(), + "decision_request_id": "dr-dec-1-2", + "reason_code": "contract_boundary_required", + "boundary_type": "accepted_behavior", + "proposed_change": "Change the public CLI contract for retained lane recovery.", + "why_exceeds_authority": "The accepted issue did not authorize changing public CLI behavior.", + "options": [ + { + "label": "accept", + "description": "Authorize the CLI contract change and update the Decision Contract." + }, + { + "label": "reject", + "description": "Keep the current contract and stop this recovery path." + } + ], + "recommendation": "Revise the Decision Contract before resuming automation.", + "resume_condition": "Automation may resume after the issue or Decision Contract explicitly authorizes the boundary change.", + "retained_worktree_evidence": ["retained worktree has tracked changes"], + "retained_diff_evidence": [private_diff_evidence], + "recovery_attempt_context": ["authority boundary check required human direction"] + }); + + let comment_response = DynamicToolHandler::handle_call(&bridge, ISSUE_COMMENT_TOOL_NAME, args); + + assert!(label_response.success); + assert!(comment_response.success); + + let comments = tracker.comments.borrow(); + let comment = comments.first().expect("decision request summary should write"); + let record = records::parse_linear_execution_event_record(comment) + .expect("decision request summary should include a ledger record"); + let events = state_store + .list_private_execution_events( + TEST_SERVICE_ID, + &issue.id, + "pub-618-attempt-2-123", + 2, + ) + .expect("private decision event should list"); + let decision_event = events + .iter() + .find(|event| event.event_type() == "authority_decision_request") + .expect("private decision request should persist"); + + assert_eq!(record.event_type, "needs_attention"); + assert_eq!(record.error_class.as_deref(), Some("contract_boundary_required")); + assert!(comment.contains("- decision_request_id: `dr-dec-1-2`")); + assert!(comment.contains("- boundary: `accepted_behavior`")); + assert!(comment.contains("- recommendation: Revise the Decision Contract")); + assert!(!comment.contains(private_diff_evidence)); + assert_eq!( + decision_event.payload()["decision_request_id"], + serde_json::json!("dr-dec-1-2") + ); + assert_eq!( + decision_event.payload()["retained_diff_evidence"][0], + serde_json::json!(private_diff_evidence) + ); +} + #[test] fn rejects_arbitrary_issue_comment_bodies() { let tracker = FakeTracker::new(); diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs index ede9897bf..8dedbd485 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs @@ -1,25 +1,22 @@ use serde_json::{self, Value}; -use crate::{ - agent::tracker_tool_bridge::{ - self, CommentArgs, DynamicToolCallResponse, DynamicToolSpec, ExecutionProgressPhase, - ISSUE_COMMENT_TOOL_NAME, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, - ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, - ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, - ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, - ISSUE_TRANSITION_TOOL_NAME, LabelArgs, NormalizedProgressCheckpoint, - NormalizedReviewCheckpointPayload, PendingReviewAction, PendingReviewCompletion, - ProgressCheckpointArgs, ReviewCheckpointArgs, ReviewCheckpointChecksArgs, - ReviewCheckpointFindingArgs, ReviewCheckpointRejectedFindingArgs, ReviewExecutionMode, - ReviewHandoffArgs, ReviewHandoffContext, ReviewPolicyPhase, ReviewPolicyStatus, - RunCompletionDisposition, TerminalFinalizeArgs, TrackerToolBridge, TransitionArgs, - }, - state::{self, StateStore}, - tracker::{ - self, records, +use crate::{agent::tracker_tool_bridge::{ + self, AuthorityDecisionOptionArgs, AuthorityDecisionRequestArgs, CommentArgs, + DynamicToolCallResponse, DynamicToolSpec, ExecutionProgressPhase, ISSUE_COMMENT_TOOL_NAME, + ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, + ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, + ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, + ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, LabelArgs, + NormalizedProgressCheckpoint, NormalizedReviewCheckpointPayload, PendingReviewAction, + PendingReviewCompletion, ProgressCheckpointArgs, ReviewCheckpointArgs, + ReviewCheckpointChecksArgs, ReviewCheckpointFindingArgs, + ReviewCheckpointRejectedFindingArgs, ReviewExecutionMode, ReviewHandoffArgs, + ReviewHandoffContext, ReviewPolicyPhase, ReviewPolicyStatus, RunCompletionDisposition, + TerminalFinalizeArgs, TrackerToolBridge, TransitionArgs, + }, orchestrator::{self, AuthorityDecisionOption, AuthorityDecisionRequestInput, AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE}, state::{self, StateStore}, tracker::{ + self, public_text, records, records::{LinearExecutionEventIdentity, LinearExecutionEventRecord}, - }, -}; + }}; const COMMENT_KIND_MANUAL_ATTENTION: &str = "manual_attention"; const MANUAL_ATTENTION_TERMINAL_PATH: &str = "manual_attention"; @@ -34,6 +31,29 @@ struct NormalizedManualAttentionComment { failed_command: Option, raw_error: Option, summary: Option, + decision_request: Option, +} + +#[derive(Debug)] +struct NormalizedAuthorityDecisionRequest { + boundary_check_id: i64, + decision_request_id: String, + reason_code: String, + boundary_type: String, + proposed_change: String, + why_exceeds_authority: String, + options: Vec, + recommendation: String, + resume_condition: String, + retained_worktree_evidence: Vec, + retained_diff_evidence: Vec, + recovery_attempt_context: Vec, +} + +#[derive(Debug)] +struct NormalizedAuthorityDecisionOption { + label: String, + description: String, } struct ReviewCheckpointPayloadCounts { @@ -118,7 +138,7 @@ impl<'a> TrackerToolBridge<'a> { pub(super) fn comment_tool_specs(&self) -> Vec { vec![DynamicToolSpec::new( ISSUE_COMMENT_TOOL_NAME, - "Add an allowlisted public summary comment to the currently leased issue. The supported automation kind is `manual_attention`; Decodex renders the Linear comment from structured public fields.", + "Add an allowlisted public summary comment to the currently leased issue. The supported automation kind is `manual_attention`; Decodex renders the Linear comment from structured public fields and may attach a durable authority-boundary decision request.", serde_json::json!({ "type": "object", "properties": { @@ -140,7 +160,56 @@ impl<'a> TrackerToolBridge<'a> { }, "failed_command": { "type": "string" }, "raw_error": { "type": "string" }, - "summary": { "type": "string" } + "summary": { "type": "string" }, + "decision_request": { + "type": "object", + "properties": { + "boundary_check_id": { "type": "integer" }, + "decision_request_id": { "type": "string" }, + "reason_code": { "type": "string" }, + "boundary_type": { "type": "string" }, + "proposed_change": { "type": "string" }, + "why_exceeds_authority": { "type": "string" }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { "type": "string" }, + "description": { "type": "string" } + }, + "required": ["label", "description"], + "additionalProperties": false + } + }, + "recommendation": { "type": "string" }, + "resume_condition": { "type": "string" }, + "retained_worktree_evidence": { + "type": "array", + "items": { "type": "string" } + }, + "retained_diff_evidence": { + "type": "array", + "items": { "type": "string" } + }, + "recovery_attempt_context": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": [ + "boundary_check_id", + "decision_request_id", + "reason_code", + "boundary_type", + "proposed_change", + "why_exceeds_authority", + "options", + "recommendation", + "resume_condition" + ], + "additionalProperties": false + } }, "required": ["kind", "error_class", "next_action", "blockers", "evidence"], "additionalProperties": false @@ -792,6 +861,16 @@ impl<'a> TrackerToolBridge<'a> { Ok(comment) => comment, Err(error) => return DynamicToolCallResponse::failure(error), }; + + if let Some(decision_request) = comment.decision_request.as_ref() + && let Err(error) = self.append_private_authority_decision_request( + review_context, + state_store, + decision_request, + ) { + return DynamicToolCallResponse::failure(error); + } + let record = self.manual_attention_execution_event(review_context, &comment); let body = format_manual_attention_comment(review_context, &comment); let projection = match tracker::prepare_linear_execution_event_comment( @@ -844,6 +923,8 @@ impl<'a> TrackerToolBridge<'a> { tracker_tool_bridge::normalize_optional_progress_field(parsed.failed_command); let raw_error = tracker_tool_bridge::normalize_optional_progress_field(parsed.raw_error); let summary = tracker_tool_bridge::normalize_optional_progress_field(parsed.summary); + let decision_request = + parsed.decision_request.map(Self::normalize_authority_decision_request).transpose()?; validate_public_error_class(&error_class)?; @@ -866,6 +947,205 @@ impl<'a> TrackerToolBridge<'a> { failed_command, raw_error, summary, + decision_request, + }) + } + + fn normalize_authority_decision_request( + parsed: AuthorityDecisionRequestArgs, + ) -> Result { + let decision_request_id = normalize_required_decision_request_field( + Some(parsed.decision_request_id), + "decision_request_id", + )?; + let reason_code = + normalize_required_decision_request_field(Some(parsed.reason_code), "reason_code")?; + let boundary_type = + normalize_required_decision_request_field(Some(parsed.boundary_type), "boundary_type")?; + let proposed_change = normalize_required_decision_request_field( + Some(parsed.proposed_change), + "proposed_change", + )?; + let why_exceeds_authority = normalize_required_decision_request_field( + Some(parsed.why_exceeds_authority), + "why_exceeds_authority", + )?; + let recommendation = normalize_required_decision_request_field( + Some(parsed.recommendation), + "recommendation", + )?; + let resume_condition = normalize_required_decision_request_field( + Some(parsed.resume_condition), + "resume_condition", + )?; + let options = parsed + .options + .into_iter() + .map(Self::normalize_authority_decision_option) + .collect::, _>>()?; + let retained_worktree_evidence = + tracker_tool_bridge::normalize_progress_list(parsed.retained_worktree_evidence); + let retained_diff_evidence = + tracker_tool_bridge::normalize_progress_list(parsed.retained_diff_evidence); + let recovery_attempt_context = + tracker_tool_bridge::normalize_progress_list(parsed.recovery_attempt_context); + + if parsed.boundary_check_id < 1 { + return Err(String::from( + "`decision_request.boundary_check_id` must be a positive private evidence record id.", + )); + } + + validate_public_error_class(&reason_code)?; + validate_public_error_class(&boundary_type)?; + + if options.is_empty() { + return Err(String::from( + "`decision_request.options` must include at least one public option.", + )); + } + + validate_public_decision_request_text( + &decision_request_id, + &proposed_change, + &why_exceeds_authority, + &options, + &recommendation, + &resume_condition, + )?; + + Ok(NormalizedAuthorityDecisionRequest { + boundary_check_id: parsed.boundary_check_id, + decision_request_id, + reason_code, + boundary_type, + proposed_change, + why_exceeds_authority, + options, + recommendation, + resume_condition, + retained_worktree_evidence, + retained_diff_evidence, + recovery_attempt_context, + }) + } + + fn normalize_authority_decision_option( + parsed: AuthorityDecisionOptionArgs, + ) -> Result { + let label = normalize_required_decision_request_field(Some(parsed.label), "option.label")?; + let description = normalize_required_decision_request_field( + Some(parsed.description), + "option.description", + )?; + + validate_public_decision_request_field("decision_request.option.label", &label)?; + validate_public_decision_request_field( + "decision_request.option.description", + &description, + )?; + + Ok(NormalizedAuthorityDecisionOption { label, description }) + } + + fn append_private_authority_decision_request( + &self, + review_context: &ReviewHandoffContext, + state_store: &StateStore, + decision_request: &NormalizedAuthorityDecisionRequest, + ) -> Result<(), String> { + let boundary_events = state_store + .list_private_execution_events( + &review_context.service_id, + &self.issue.id, + &review_context.run_id, + review_context.attempt_number, + ) + .map_err(|error| { + format!( + "Failed to inspect authority boundary evidence for issue `{}`: {error}", + self.issue.identifier + ) + })?; + let Some(boundary_event) = boundary_events + .iter() + .find(|event| event.record_id() == decision_request.boundary_check_id) + else { + return Err(format!( + "`decision_request.boundary_check_id` {} does not reference a private event for issue `{}` run `{}` attempt {}.", + decision_request.boundary_check_id, + self.issue.identifier, + review_context.run_id, + review_context.attempt_number + )); + }; + + if boundary_event.event_type() != AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE { + return Err(format!( + "`decision_request.boundary_check_id` {} references `{}` instead of an authority boundary check.", + decision_request.boundary_check_id, + boundary_event.event_type() + )); + } + + let disposition = boundary_event.payload().get("disposition").and_then(Value::as_str); + + if disposition != Some("requires_human") { + return Err(format!( + "`decision_request.boundary_check_id` {} must reference a `requires_human` authority boundary check.", + decision_request.boundary_check_id + )); + } + + let options = decision_request + .options + .iter() + .map(|option| AuthorityDecisionOption { + label: option.label.as_str(), + description: option.description.as_str(), + }) + .collect::>(); + let retained_worktree_evidence = decision_request + .retained_worktree_evidence + .iter() + .map(String::as_str) + .collect::>(); + let retained_diff_evidence = + decision_request.retained_diff_evidence.iter().map(String::as_str).collect::>(); + let recovery_attempt_context = decision_request + .recovery_attempt_context + .iter() + .map(String::as_str) + .collect::>(); + + orchestrator::record_authority_decision_request_private_event( + state_store, + AuthorityDecisionRequestInput { + project_id: &review_context.service_id, + issue_id: &self.issue.id, + issue_identifier: &self.issue.identifier, + run_id: &review_context.run_id, + attempt_number: review_context.attempt_number, + boundary_check_record_id: decision_request.boundary_check_id, + decision_request_id: &decision_request.decision_request_id, + reason_code: &decision_request.reason_code, + boundary_type: &decision_request.boundary_type, + proposed_change: &decision_request.proposed_change, + why_exceeds_authority: &decision_request.why_exceeds_authority, + options, + recommendation: &decision_request.recommendation, + resume_condition: &decision_request.resume_condition, + retained_worktree_evidence, + retained_diff_evidence, + recovery_attempt_context, + }, + ) + .map(|_| ()) + .map_err(|error| { + format!( + "Failed to persist authority decision request `{}` for issue `{}`: {error}", + decision_request.decision_request_id, self.issue.identifier + ) }) } @@ -874,12 +1154,18 @@ impl<'a> TrackerToolBridge<'a> { review_context: &ReviewHandoffContext, comment: &NormalizedManualAttentionComment, ) -> LinearExecutionEventRecord { + let decision_request_id = comment + .decision_request + .as_ref() + .map(|request| request.decision_request_id.as_str()) + .unwrap_or_default(); let anchor = records::stable_event_anchor(&[ COMMENT_KIND_MANUAL_ATTENTION, comment.error_class.as_str(), comment.next_action.as_str(), comment.failed_command.as_deref().unwrap_or_default(), comment.raw_error.as_deref().unwrap_or_default(), + decision_request_id, ]); let mut record = LinearExecutionEventRecord::new( LinearExecutionEventIdentity { @@ -1508,6 +1794,49 @@ fn normalize_required_comment_field( Ok(value) } +fn normalize_required_decision_request_field( + value: Option, + field_name: &str, +) -> Result { + tracker_tool_bridge::normalize_optional_progress_field(value) + .ok_or_else(|| format!("`decision_request.{field_name}` must be present and non-empty.")) +} + +fn validate_public_decision_request_text( + decision_request_id: &str, + proposed_change: &str, + why_exceeds_authority: &str, + options: &[NormalizedAuthorityDecisionOption], + recommendation: &str, + resume_condition: &str, +) -> Result<(), String> { + validate_public_decision_request_field( + "decision_request.decision_request_id", + decision_request_id, + )?; + validate_public_decision_request_field("decision_request.proposed_change", proposed_change)?; + validate_public_decision_request_field( + "decision_request.why_exceeds_authority", + why_exceeds_authority, + )?; + validate_public_decision_request_field("decision_request.recommendation", recommendation)?; + validate_public_decision_request_field("decision_request.resume_condition", resume_condition)?; + + for option in options { + validate_public_decision_request_field("decision_request.option.label", &option.label)?; + validate_public_decision_request_field( + "decision_request.option.description", + &option.description, + )?; + } + + Ok(()) +} + +fn validate_public_decision_request_field(field_name: &str, value: &str) -> Result<(), String> { + public_text::validate_public_text_field(field_name, value).map_err(|error| error.to_string()) +} + fn normalize_review_checkpoint_payload( parsed: ReviewCheckpointArgs, status: ReviewPolicyStatus, @@ -1741,6 +2070,21 @@ fn format_manual_attention_comment( lines.push(format!("- evidence: {evidence}")); } + if let Some(request) = comment.decision_request.as_ref() { + lines.push(String::from("- decision_request: authority_boundary")); + lines.push(format!("- decision_request_id: `{}`", request.decision_request_id)); + lines.push(format!("- decision_reason: `{}`", request.reason_code)); + lines.push(format!("- boundary: `{}`", request.boundary_type)); + lines.push(format!("- proposed_change: {}", request.proposed_change)); + lines.push(format!("- why_exceeds_authority: {}", request.why_exceeds_authority)); + + for option in &request.options { + lines.push(format!("- decision_option: `{}` - {}", option.label, option.description)); + } + + lines.push(format!("- recommendation: {}", request.recommendation)); + lines.push(format!("- resume_condition: {}", request.resume_condition)); + } if let Some(failed_command) = comment.failed_command.as_deref() { lines.push(format!("- failed_command: {failed_command}")); } diff --git a/apps/decodex/src/orchestrator/agent_evidence.rs b/apps/decodex/src/orchestrator/agent_evidence.rs index 11fa04ec0..b6ab20654 100644 --- a/apps/decodex/src/orchestrator/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/agent_evidence.rs @@ -259,11 +259,23 @@ struct PrivateEvidenceReadback { event_count: usize, latest_event_type: Option, latest_event_at: Option, + decision_requests: Vec, improvement_candidates: Vec, events: Vec, warnings: Vec, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct PrivateEvidenceDecisionRequestSummary { + decision_request_id: String, + phase: String, + reason: String, + boundary: String, + next_action: String, + recommendation: Option, + resume_condition: Option, +} + #[derive(Clone, Debug, PartialEq, Serialize)] struct PrivateEvidenceReadbackEvent { record_id: i64, @@ -647,6 +659,7 @@ fn build_private_evidence_readback( event_count: events.len(), latest_event_type: latest_event.map(|event| event.event_type().to_owned()), latest_event_at: latest_event.map(|event| event.recorded_at().to_owned()), + decision_requests: authority_decision_requests_from_private_events(&events), improvement_candidates: harness_improvement_candidates_from_private_events(&events), events: events .iter() @@ -742,6 +755,53 @@ fn private_evidence_direct_lookup_issue_id( ) } +fn authority_decision_requests_from_private_events( + events: &[state::PrivateExecutionEvent], +) -> Vec { + events + .iter() + .filter(|event| event.event_type() == AUTHORITY_DECISION_REQUEST_EVENT_TYPE) + .filter_map(authority_decision_request_from_private_event) + .collect() +} + +fn authority_decision_request_from_private_event( + event: &state::PrivateExecutionEvent, +) -> Option { + let payload = event.payload(); + let decision_request_id = payload.get("decision_request_id")?.as_str()?.to_owned(); + let reason = payload.get("reason")?.as_str()?.to_owned(); + let boundary = payload.get("boundary")?.as_str()?.to_owned(); + let phase = payload + .get("phase") + .and_then(Value::as_str) + .unwrap_or("human_required") + .to_owned(); + let next_action = payload + .get("next_action") + .or_else(|| payload.get("resume_condition"))? + .as_str()? + .to_owned(); + let recommendation = payload + .get("recommendation") + .and_then(Value::as_str) + .map(str::to_owned); + let resume_condition = payload + .get("resume_condition") + .and_then(Value::as_str) + .map(str::to_owned); + + Some(PrivateEvidenceDecisionRequestSummary { + decision_request_id, + phase, + reason, + boundary, + next_action, + recommendation, + resume_condition, + }) +} + fn private_evidence_run_matches_issue( project: &ServiceConfig, run: &ProjectRunStatus, @@ -898,6 +958,10 @@ fn render_private_evidence_readback(readback: &PrivateEvidenceReadback) -> Strin "improvement_candidate_count: {}\n", readback.improvement_candidates.len() )); + output.push_str(&format!( + "decision_request_count: {}\n", + readback.decision_requests.len() + )); output.push_str(&format!( "latest_event_type: {}\n", readback.latest_event_type.as_deref().unwrap_or("none") @@ -911,6 +975,23 @@ fn render_private_evidence_readback(readback: &PrivateEvidenceReadback) -> Strin output.push_str(&format!("warnings: {}\n", readback.warnings.join(", "))); } + output.push_str("\nDecision Requests\n"); + + if readback.decision_requests.is_empty() { + output.push_str("- none\n"); + } else { + for request in &readback.decision_requests { + output.push_str(&format!( + "- id: {}\n phase: {}\n reason: {}\n boundary: {}\n next_action: {}\n", + request.decision_request_id, + request.phase, + request.reason, + request.boundary, + request.next_action + )); + } + } + output.push_str("\nImprovement Candidates\n"); if readback.improvement_candidates.is_empty() { diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 2b85c53f3..8d3775598 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -2355,6 +2355,13 @@ where }; let attention_next_action = attention_record.as_ref().and_then(|record| record.next_action.clone()); + let decision_request = operator_queued_issue_decision_request_status( + project, + state_store, + issue, + attention_record.as_ref(), + marker.as_ref(), + )?; let attempt_status = marker .as_ref() .and_then(|marker| state_store.run_attempt(marker.run_id()).transpose()) @@ -2373,6 +2380,7 @@ where Ok(Some(OperatorQueuedIssueAttentionStatus { summary, + decision_request, run_id: marker.as_ref().map(|marker| marker.run_id().to_owned()), attempt_number: marker.as_ref().map(RunActivityMarker::attempt_number), current_operation: marker @@ -2411,6 +2419,73 @@ where })) } +fn operator_queued_issue_decision_request_status( + project: &ServiceConfig, + state_store: &StateStore, + issue: &TrackerIssue, + attention_record: Option<&LinearExecutionEventRecord>, + marker: Option<&RunActivityMarker>, +) -> crate::prelude::Result> { + let run_id = attention_record + .map(|record| record.run_id.as_str()) + .or_else(|| marker.map(RunActivityMarker::run_id)); + let attempt_number = attention_record + .map(|record| record.attempt_number) + .or_else(|| marker.map(RunActivityMarker::attempt_number)); + let (Some(run_id), Some(attempt_number)) = (run_id, attempt_number) else { + return Ok(None); + }; + let events = state_store.list_private_execution_events( + project.service_id(), + &issue.id, + run_id, + attempt_number, + )?; + + Ok(events + .iter() + .rev() + .find(|event| event.event_type() == AUTHORITY_DECISION_REQUEST_EVENT_TYPE) + .and_then(operator_authority_decision_request_status_from_event)) +} + +fn operator_authority_decision_request_status_from_event( + event: &PrivateExecutionEvent, +) -> Option { + let payload = event.payload(); + let decision_request_id = payload.get("decision_request_id")?.as_str()?.to_owned(); + let reason = payload.get("reason")?.as_str()?.to_owned(); + let boundary = payload.get("boundary")?.as_str()?.to_owned(); + let phase = payload + .get("phase") + .and_then(Value::as_str) + .unwrap_or("human_required") + .to_owned(); + let next_action = payload + .get("next_action") + .or_else(|| payload.get("resume_condition"))? + .as_str()? + .to_owned(); + let recommendation = payload + .get("recommendation") + .and_then(Value::as_str) + .map(str::to_owned); + let resume_condition = payload + .get("resume_condition") + .and_then(Value::as_str) + .map(str::to_owned); + + Some(OperatorAuthorityDecisionRequestStatus { + phase, + reason, + boundary, + decision_request_id, + next_action, + recommendation, + resume_condition, + }) +} + fn operator_queued_issue_private_evidence_missing( project: &ServiceConfig, state_store: &StateStore, @@ -5850,6 +5925,17 @@ fn append_rendered_queued_issue( attention.worktree_path.as_deref().unwrap_or("none"), attention.last_activity_at.as_deref().unwrap_or("none"), )); + + if let Some(decision_request) = attention.decision_request.as_ref() { + output.push_str(&format!( + " decision_request_phase: {}\n decision_request_reason: {}\n decision_request_boundary: {}\n decision_request_id: {}\n decision_request_next_action: {}\n", + decision_request.phase, + decision_request.reason, + decision_request.boundary, + decision_request.decision_request_id, + decision_request.next_action + )); + } } } diff --git a/apps/decodex/src/orchestrator/tests.rs b/apps/decodex/src/orchestrator/tests.rs index 6ca2a8722..4842087be 100644 --- a/apps/decodex/src/orchestrator/tests.rs +++ b/apps/decodex/src/orchestrator/tests.rs @@ -34,7 +34,7 @@ use crate::config::{InternalReviewMode, ServiceConfig}; #[rustfmt::skip] use crate::github; #[rustfmt::skip] -use crate::orchestrator::{self, ActiveChildRunContext, ActiveRunDisposition, ActiveRunReconciliation, ActiveWorkflowOverride, AgentEvidenceSource, ChildExitRetryContext, ChildRunRef, ControlPlaneProjectTick, CONTINUATION_PENDING_RUN_STATUS, DaemonRunChild, DaemonTickRuntimeContext, DashboardEventHub, EvidenceRequest, GhPullRequestReviewStateInspector, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, IssueDispatchMode, IssueRunPlan, IssueTurnContinuationGuard, ManualAttentionRequested, OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, OPERATOR_DASHBOARD_ENDPOINT_PATH, OperatorCodexAccountControlStatus, OperatorExecutionProgramStatus, OperatorGitHubCliAuthority, OperatorProjectStatus, OperatorStatusSnapshot, PostReviewLaneClassification, PostReviewLaneDecision, PostReviewLaneSnapshot, PreferredRunIdentity, PrepareIssueRunContext, PublishedOperatorSnapshot, PullRequestCommitConnection, PullRequestCommitNode, PullRequestCommitPayload, PullRequestIssueCommentConnection, PullRequestIssueCommentState, PullRequestIssueCommentsNode, PullRequestPageInfo, PullRequestReactionGroup, PullRequestReactionUsersConnection, PullRequestActor, PullRequestRepository, PullRequestRepositoryOwner, PullRequestReviewConnection, PullRequestIssueCommentNode, PullRequestReviewNode, PullRequestReviewRequestConnection, PullRequestReviewState, PullRequestReviewStateInspector, PullRequestReviewStateNode, PullRequestReviewStateRepository, PullRequestReviewSummaryState, PullRequestReviewThreadConnection, PullRequestReviewThreadNode, PullRequestStatusCheckRollup, RecoveredRuntimeState, RetainedPartialProgress, RetainedReviewRunIdentity, RetryComment, RetryDispatchDecision, RetryEntry, RetryKind, RetryQueue, RunCompletionDisposition, RunSummary, RepoGateFailure, TERMINAL_GUARD_MARKER_FILE, TERMINAL_GUARDED_RUN_STATUS, TRACKER_RATE_LIMIT_WARNING, TargetIssueRunContext, EXTERNAL_REVIEW_ACTOR_LOGIN, EXTERNAL_REVIEW_PASS_PHRASE, EXTERNAL_REVIEW_REQUEST_BODY}; +use crate::orchestrator::{self, ActiveChildRunContext, ActiveRunDisposition, ActiveRunReconciliation, ActiveWorkflowOverride, AgentEvidenceSource, AuthorityBoundaryCheckInput, AuthorityBoundaryDisposition, AuthorityDecisionRequestInput, ChildExitRetryContext, ChildRunRef, ControlPlaneProjectTick, CONTINUATION_PENDING_RUN_STATUS, DaemonRunChild, DaemonTickRuntimeContext, DashboardEventHub, EvidenceRequest, GhPullRequestReviewStateInspector, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, IssueDispatchMode, IssueRunPlan, IssueTurnContinuationGuard, ManualAttentionRequested, OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, OPERATOR_DASHBOARD_ENDPOINT_PATH, OperatorCodexAccountControlStatus, OperatorExecutionProgramStatus, OperatorGitHubCliAuthority, OperatorProjectStatus, OperatorStatusSnapshot, PostReviewLaneClassification, PostReviewLaneDecision, PostReviewLaneSnapshot, PreferredRunIdentity, PrepareIssueRunContext, PublishedOperatorSnapshot, PullRequestCommitConnection, PullRequestCommitNode, PullRequestCommitPayload, PullRequestIssueCommentConnection, PullRequestIssueCommentState, PullRequestIssueCommentsNode, PullRequestPageInfo, PullRequestReactionGroup, PullRequestReactionUsersConnection, PullRequestActor, PullRequestRepository, PullRequestRepositoryOwner, PullRequestReviewConnection, PullRequestIssueCommentNode, PullRequestReviewNode, PullRequestReviewRequestConnection, PullRequestReviewState, PullRequestReviewStateInspector, PullRequestReviewStateNode, PullRequestReviewStateRepository, PullRequestReviewSummaryState, PullRequestReviewThreadConnection, PullRequestReviewThreadNode, PullRequestStatusCheckRollup, RecoveredRuntimeState, RetainedPartialProgress, RetainedReviewRunIdentity, RetryComment, RetryDispatchDecision, RetryEntry, RetryKind, RetryQueue, RunCompletionDisposition, RunSummary, RepoGateFailure, TERMINAL_GUARD_MARKER_FILE, TERMINAL_GUARDED_RUN_STATUS, TRACKER_RATE_LIMIT_WARNING, TargetIssueRunContext, EXTERNAL_REVIEW_ACTOR_LOGIN, EXTERNAL_REVIEW_PASS_PHRASE, EXTERNAL_REVIEW_REQUEST_BODY}; #[rustfmt::skip] use crate::prelude::Result; #[rustfmt::skip] 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 c02a96f38..86e38ea92 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs @@ -1,7 +1,5 @@ use orchestrator::HarnessOutcomeKind; use orchestrator::HarnessOutcomeRecordInput; -use orchestrator::AuthorityBoundaryCheckInput; -use orchestrator::AuthorityBoundaryDisposition; use crate::loop_contract::DecisionContract; @@ -338,6 +336,102 @@ fn agent_evidence_authority_boundary_readback_recommends_candidates_without_payl assert!(!rendered.contains(private_marker)); } +#[test] +fn agent_evidence_private_readback_summarizes_authority_decision_request_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_diff_evidence = "PRIVATE_DECISION_REQUEST_DIFF_PAYLOAD"; + + state_store + .upsert_worktree( + TEST_SERVICE_ID, + "issue-decision-request", + "x/pubfi-pub-112", + ".worktrees/PUB-112", + ) + .expect("worktree should persist"); + state_store + .record_run_attempt("run-decision-request", "issue-decision-request", 1, "terminal_guarded") + .expect("run should persist"); + + let boundary_event = orchestrator::record_authority_boundary_check_private_event( + &state_store, + AuthorityBoundaryCheckInput { + project_id: TEST_SERVICE_ID, + issue_id: "issue-decision-request", + issue_identifier: "PUB-112", + run_id: "run-decision-request", + attempt_number: 1, + decision_contract_ids: vec!["contract-decision-request"], + 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::new(), + }, + ) + .expect("authority boundary check should persist"); + + orchestrator::record_authority_decision_request_private_event( + &state_store, + AuthorityDecisionRequestInput { + project_id: TEST_SERVICE_ID, + issue_id: "issue-decision-request", + issue_identifier: "PUB-112", + run_id: "run-decision-request", + attempt_number: 1, + boundary_check_record_id: boundary_event.record_id(), + decision_request_id: "dr-pub-112-1", + reason_code: "contract_boundary_required", + boundary_type: "accepted_behavior", + proposed_change: "Change accepted operator behavior.", + why_exceeds_authority: "The current issue did not authorize the behavior change.", + options: vec![orchestrator::AuthorityDecisionOption { + label: "revise", + description: "Update the Decision Contract before resuming.", + }], + recommendation: "Revise the Decision Contract before resuming automation.", + resume_condition: "Clear needs-attention and requeue only after authority is updated.", + retained_worktree_evidence: vec!["retained worktree has tracked changes"], + retained_diff_evidence: vec![private_diff_evidence], + recovery_attempt_context: vec!["recovery stopped at the authority boundary"], + }, + ) + .expect("authority decision request should persist"); + + let request = EvidenceRequest { + config_path: None, + issue: "PUB-112", + run_id: Some("run-decision-request"), + attempt_number: Some(1), + json: true, + include_payload: false, + }; + let readback = orchestrator::build_private_evidence_readback( + &state_store, + &config, + &request, + ) + .expect("decision request evidence should read"); + let rendered = orchestrator::render_private_evidence_readback(&readback); + + assert_eq!(readback.decision_requests.len(), 1); + assert_eq!( + readback.decision_requests[0].decision_request_id, + "dr-pub-112-1" + ); + assert_eq!(readback.decision_requests[0].phase, "human_required"); + assert_eq!(readback.decision_requests[0].reason, "contract_boundary_required"); + assert!(rendered.contains("Decision Requests")); + assert!(rendered.contains("dr-pub-112-1")); + assert!(!rendered.contains(private_diff_evidence)); + assert!(readback.events.iter().all(|event| event.payload.is_none())); +} + #[test] fn private_evidence_readback_reports_missing_events_for_known_run() { let (_temp_dir, config, _workflow) = temp_project_layout(); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/queue.rs b/apps/decodex/src/orchestrator/tests/operator/status/queue.rs index 9bbcc06f6..36d4f39af 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/queue.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/queue.rs @@ -760,6 +760,122 @@ fn live_operator_status_snapshot_surfaces_needs_attention_event_cause() { assert!(rendered.contains("attention_next_action: inspect retained review orchestration")); } +#[test] +fn live_operator_status_snapshot_surfaces_authority_decision_request() { + let (_temp_dir, config, workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let issue = sample_issue_with_sort_fields( + "issue-decision-request", + "PUB-118", + "Todo", + &["decodex:needs-attention"], + Some(2), + "2026-03-13T09:16:17.133Z", + ); + let tracker = FakeTracker::new(vec![issue.clone()]); + + tracker.issue_comments.borrow_mut().insert( + issue.id.clone(), + vec![linear_execution_history_comment( + &issue, + "needs_attention", + "2026-03-13T09:20:00Z", + "contract-boundary-required", + |record| { + record.error_class = Some(String::from("contract_boundary_required")); + record.next_action = Some(String::from( + "accept, reject, or revise decision request `dr-pub-118-1`, then clear needs-attention and requeue through Decodex", + )); + record.summary = + Some(String::from("Authority boundary requires a human decision.")); + record.blockers = Some(vec![String::from( + "accepted behavior change exceeds current authority", + )]); + record.evidence = Some(vec![String::from( + "authority boundary check requires human direction", + )]); + record.terminal_path = Some(String::from("manual_attention")); + }, + )], + ); + + let boundary_event = orchestrator::record_authority_boundary_check_private_event( + &state_store, + AuthorityBoundaryCheckInput { + project_id: TEST_SERVICE_ID, + issue_id: &issue.id, + issue_identifier: &issue.identifier, + run_id: "xy-355-attempt-1-1777527013", + attempt_number: 1, + decision_contract_ids: vec!["contract-pub-118"], + 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::new(), + }, + ) + .expect("boundary event should persist"); + + orchestrator::record_authority_decision_request_private_event( + &state_store, + AuthorityDecisionRequestInput { + project_id: TEST_SERVICE_ID, + issue_id: &issue.id, + issue_identifier: &issue.identifier, + run_id: "xy-355-attempt-1-1777527013", + attempt_number: 1, + boundary_check_record_id: boundary_event.record_id(), + decision_request_id: "dr-pub-118-1", + reason_code: "contract_boundary_required", + boundary_type: "accepted_behavior", + proposed_change: "Change accepted operator behavior.", + why_exceeds_authority: "The current issue did not authorize the behavior change.", + options: vec![orchestrator::AuthorityDecisionOption { + label: "revise", + description: "Update the Decision Contract before resuming.", + }], + recommendation: "Revise the Decision Contract before resuming automation.", + resume_condition: "Clear needs-attention and requeue only after authority is updated.", + retained_worktree_evidence: vec!["retained worktree has tracked changes"], + retained_diff_evidence: vec!["private diff summary retained locally"], + recovery_attempt_context: vec!["recovery stopped at the authority boundary"], + }, + ) + .expect("decision request should persist"); + + let snapshot = orchestrator::build_live_operator_status_snapshot( + &tracker, + &config, + &workflow, + &state_store, + 10, + ) + .expect("snapshot should build"); + let candidate = snapshot + .queued_candidates + .iter() + .find(|candidate| candidate.issue_identifier == "PUB-118") + .expect("needs-attention queued issue should exist"); + let decision_request = candidate + .attention + .as_ref() + .and_then(|attention| attention.decision_request.as_ref()) + .expect("decision request should render"); + let rendered = orchestrator::render_operator_status(&snapshot); + + assert_eq!(decision_request.phase, "human_required"); + assert_eq!(decision_request.reason, "contract_boundary_required"); + assert_eq!(decision_request.boundary, "accepted_behavior"); + assert_eq!(decision_request.decision_request_id, "dr-pub-118-1"); + assert!(rendered.contains("decision_request_phase: human_required")); + assert!(rendered.contains("decision_request_id: dr-pub-118-1")); +} + #[test] fn live_operator_status_snapshot_surfaces_plugin_list_preflight_timeout() { let (_temp_dir, config, workflow) = temp_project_layout(); diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index cb9a3ba4f..7cc6d07d0 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -5,10 +5,14 @@ use crate::tracker; type PullRequestReadbackResult = std::result::Result; +pub(crate) const AUTHORITY_DECISION_REQUEST_SCHEMA: &str = + "decodex.authority_decision_request/1"; +pub(crate) const AUTHORITY_DECISION_REQUEST_EVENT_TYPE: &str = "authority_decision_request"; #[allow(dead_code)] -const AUTHORITY_BOUNDARY_CHECK_SCHEMA: &str = "decodex.authority_boundary_check/1"; +pub(crate) const AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE: &str = "authority_boundary_check"; + #[allow(dead_code)] -const AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE: &str = "authority_boundary_check"; +const AUTHORITY_BOUNDARY_CHECK_SCHEMA: &str = "decodex.authority_boundary_check/1"; trait PullRequestReviewStateInspector { fn inspect_review_state( @@ -324,6 +328,35 @@ pub(crate) struct AuthorityBoundaryCheckInput<'a> { pub(crate) improvement_signals: Vec>, } +/// One public-safe option offered in a durable authority decision request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct AuthorityDecisionOption<'a> { + pub(crate) label: &'a str, + pub(crate) description: &'a str, +} + +/// Input for persisting the full local decision packet for an authority-boundary stop. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AuthorityDecisionRequestInput<'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) boundary_check_record_id: i64, + pub(crate) decision_request_id: &'a str, + pub(crate) reason_code: &'a str, + pub(crate) boundary_type: &'a str, + pub(crate) proposed_change: &'a str, + pub(crate) why_exceeds_authority: &'a str, + pub(crate) options: Vec>, + pub(crate) recommendation: &'a str, + pub(crate) resume_condition: &'a str, + pub(crate) retained_worktree_evidence: Vec<&'a str>, + pub(crate) retained_diff_evidence: Vec<&'a str>, + pub(crate) recovery_attempt_context: Vec<&'a str>, +} + /// One bounded run invocation and its optional daemon-planned overrides. pub(crate) struct RunOnceRequest<'a> { pub(crate) config_path: Option<&'a Path>, @@ -1402,6 +1435,8 @@ struct OperatorQueuedIssueStatus { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] struct OperatorQueuedIssueAttentionStatus { summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + decision_request: Option, run_id: Option, attempt_number: Option, current_operation: Option, @@ -1422,6 +1457,17 @@ struct OperatorQueuedIssueAttentionStatus { worktree_has_tracked_changes: bool, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct OperatorAuthorityDecisionRequestStatus { + phase: String, + reason: String, + boundary: String, + decision_request_id: String, + next_action: String, + recommendation: Option, + resume_condition: Option, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] struct OperatorWorktreeStatus { issue_id: String, @@ -1929,6 +1975,62 @@ pub(crate) fn record_authority_boundary_check_private_event( ) } +pub(crate) fn record_authority_decision_request_private_event( + state_store: &StateStore, + input: AuthorityDecisionRequestInput<'_>, +) -> Result { + validate_authority_decision_request_input(&input)?; + + let options = input + .options + .iter() + .map(|option| { + json!({ + "label": option.label, + "description": option.description, + }) + }) + .collect::>(); + let payload = json!({ + "schema": AUTHORITY_DECISION_REQUEST_SCHEMA, + "record_version": 1, + "decision_request_id": input.decision_request_id, + "issue": { + "id": input.issue_id, + "identifier": input.issue_identifier, + }, + "run": { + "run_id": input.run_id, + "attempt_number": input.attempt_number, + }, + "authority_boundary_check": { + "record_id": input.boundary_check_record_id, + "event_type": AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE, + }, + "phase": "human_required", + "reason": input.reason_code, + "boundary": input.boundary_type, + "proposed_change": input.proposed_change, + "why_exceeds_authority": input.why_exceeds_authority, + "options": options, + "recommendation": input.recommendation, + "resume_condition": input.resume_condition, + "next_action": input.resume_condition, + "retained_worktree_evidence": input.retained_worktree_evidence, + "retained_diff_evidence": input.retained_diff_evidence, + "recovery_attempt_context": input.recovery_attempt_context, + }); + + state_store.append_private_execution_event( + input.project_id, + input.issue_id, + input.run_id, + input.attempt_number, + AUTHORITY_DECISION_REQUEST_EVENT_TYPE, + payload, + ) +} + fn validate_authority_boundary_check_input( input: &AuthorityBoundaryCheckInput<'_>, ) -> Result<()> { @@ -1972,6 +2074,54 @@ fn validate_authority_boundary_check_input( Ok(()) } +fn validate_authority_decision_request_input( + input: &AuthorityDecisionRequestInput<'_>, +) -> Result<()> { + authority_boundary_required("authority decision project_id", input.project_id)?; + authority_boundary_required("authority decision issue_id", input.issue_id)?; + authority_boundary_required("authority decision issue_identifier", input.issue_identifier)?; + authority_boundary_required("authority decision run_id", input.run_id)?; + authority_boundary_required( + "authority decision decision_request_id", + input.decision_request_id, + )?; + authority_boundary_required("authority decision reason_code", input.reason_code)?; + authority_boundary_required("authority decision boundary_type", input.boundary_type)?; + authority_boundary_required("authority decision proposed_change", input.proposed_change)?; + authority_boundary_required( + "authority decision why_exceeds_authority", + input.why_exceeds_authority, + )?; + authority_boundary_required("authority decision recommendation", input.recommendation)?; + authority_boundary_required("authority decision resume_condition", input.resume_condition)?; + + if input.attempt_number < 1 { + eyre::bail!("Authority decision attempt_number must be positive."); + } + if input.boundary_check_record_id < 1 { + eyre::bail!("Authority decision boundary_check_record_id must be positive."); + } + if input.options.is_empty() { + eyre::bail!("Authority decision options must not be empty."); + } + + for option in &input.options { + authority_boundary_required("authority decision option label", option.label)?; + authority_boundary_required("authority decision option description", option.description)?; + } + for evidence in &input.retained_worktree_evidence { + authority_boundary_required("authority decision retained_worktree_evidence", evidence)?; + } + for evidence in &input.retained_diff_evidence { + authority_boundary_required("authority decision retained_diff_evidence", evidence)?; + } + for context in &input.recovery_attempt_context { + authority_boundary_required("authority decision recovery_attempt_context", context)?; + } + + Ok(()) +} + fn authority_boundary_required(name: &str, value: &str) -> Result<()> { if value.trim().is_empty() { eyre::bail!("{name} must not be empty."); diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 6b4e06db1..8a4add844 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -246,7 +246,7 @@ protocol activity durable outside the local operator surface. | `Accounts` | Shared Codex account pool and usage table from `~/.codex/decodex/accounts.jsonl` when `[codex.accounts]` is enabled for a project. Account identity can be obscured from the `Account` column header eye without changing the underlying snapshot. The row weight column shows the capacity multiplier used for pool usage estimates: `pro` accounts count as `20x`, and all other plans count as `1x`. Usage probes read Codex `/wham/usage` for window capacity and `/wham/profiles/me` for profile token stats such as lifetime tokens, peak daily tokens, longest task, streaks, and daily token activity. Selecting an account writes the global `[codex.accounts].fixed_account` selector in `~/.codex/decodex/config.toml`; clearing it returns all new account-pool runs to balanced account selection. Account display-name rerolls write `[codex.account_names.offsets]` in the same global config so Decodex App and the dashboard share the privacy-preserving names. Theme, sort, and identity-visibility preferences are client-local presentation state. The selector is global and does not pin a project to an account. | | `Projects` | Fleet-level project table. The section-level filter toggles between active project work and the full registry. Location is its own compact path column and can be obscured from the location header eye. `Activity` shows a relative timestamp or `-`; `Work` is `running/waiting/attention`. It should not duplicate per-lane details already shown below. | | `Running Lanes` | Active leased or live-executing issue lanes. A lane here is currently owned by this local control plane, or a live process/thread/protocol marker still explains active execution even when the queue lease is not held. It shows issue identity, phase, operation, attempt, queue lease state, execution liveness, thread/protocol status, child-agent activity when captured, phase-goal status when app-server reports it, timing, branch, and worktree. | -| `Intake Queue` | Queued tracker issues before execution. Candidates are classified as `ready`, capacity-waiting, claimed without a matching local lane, blocked, or closed/stale. Repeated identical open dependency blockers surface as `dependency_program_stale` after the guardrail threshold so operators can distinguish a stale Execution Program/dependency plan from a newly blocked queue item. A blocked queued candidate can still show an attached `.worktrees/XY-*` path when the queue owns the attention state; if that worktree has tracked changes after stalled reconciliation, failure writeback, or retries, the candidate is partial retained progress and not just a generic stalled or retry-budget hold. Running lanes are not repeated as normal intake work. | +| `Intake Queue` | Queued tracker issues before execution. Candidates are classified as `ready`, capacity-waiting, claimed without a matching local lane, blocked, or closed/stale. Repeated identical open dependency blockers surface as `dependency_program_stale` after the guardrail threshold so operators can distinguish a stale Execution Program/dependency plan from a newly blocked queue item. A blocked queued candidate can still show an attached `.worktrees/XY-*` path when the queue owns the attention state; if that worktree has tracked changes after stalled reconciliation, failure writeback, or retries, the candidate is partial retained progress and not just a generic stalled or retry-budget hold. Human-required authority stops expose their compact decision request fields here: `phase = human_required`, reason, boundary, `decision_request_id`, and `next_action`. Running lanes are not repeated as normal intake work. | | `Review & Landing` | Retained PR lanes after review handoff. This section owns post-review repair, wait-for-review, ready-to-land, closeout, cleanup, and blocked retained-lane visibility. | | `Recovery Worktrees` | Retained local worktrees that are not currently owned by `Running Lanes`, `Review & Landing`, or queued attention in `Intake Queue`. This is the cleanup or recovery inbox for recovered paths, retained PR leftovers, and cleanup-only local worktrees. Empty is the normal healthy state. | | `Run Ledger` | Completed or non-running issue history, grouped by issue/lane. Decodex Linear execution ledger comments provide the durable completed outcome when available. If no `decodex.linear_execution_event` record exists, the row reports `missing` / `execution_ledger_missing`; the control plane does not derive a completed or landed outcome from tracker state, local attempts, or non-ledger comments. Raw local attempts and heartbeat details stay in debug expansion. | @@ -268,7 +268,10 @@ Recommended readback sequence: 3. If `event_count` is `0` and warnings include `private_execution_evidence_missing`, use the status row, run capsule, protocol summary, retained worktree, and Linear public summary as the available evidence. -4. Use `--include-payload` only when compact payload summaries are insufficient for +4. For authority-boundary stops, inspect `decision_requests` for the public-safe + decision request id, boundary, recommendation, resume condition, and next action, + then use the linked Authority Boundary Check summary to audit the stop. +5. Use `--include-payload` only when compact payload summaries are insufficient for local repair. Do not paste full payloads into Linear or GitHub. The command does not require live Linear or GitHub observer access. It resolves known @@ -299,6 +302,9 @@ the next allowlisted lifecycle summary instead of pasting local evidence payload The same boundary applies to Decision Contracts: the operator surface may show status, readiness summary, generated issue links, or public projection references, but the versioned contract payload and private evidence references remain runtime-local. +Authority decision requests follow the same split: Linear and status show the +decision interface and resume condition, while retained worktree evidence, diff +evidence, recovery context, and boundary-check links remain local runtime evidence. Harness-outcome telemetry follows the same rule: the operator surface may show compact improvement-candidate summaries, while the correlated source intent, contract payloads, review details, validation diagnostics, and guardrail checkpoints remain private diff --git a/docs/runbook/lane-control-recovery.md b/docs/runbook/lane-control-recovery.md index 4ab5a7d53..e07351bd1 100644 --- a/docs/runbook/lane-control-recovery.md +++ b/docs/runbook/lane-control-recovery.md @@ -93,7 +93,8 @@ or clean labels. | 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. | +| Authority Boundary Check reports `requires_human`. | Stop automatic recovery and preserve the durable decision request. | Keep or apply `decodex:needs-attention`, inspect the Linear decision request and private `authority_decision_request` evidence, then continue only after the issue, Decision Contract, or policy accepts, rejects, or revises the requested authority change. | +| Authority Boundary Check reports `insufficient_evidence`. | Stop automatic recovery unless later evidence proves the change is inside authority. | Keep or apply `decodex:needs-attention`, capture the missing 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 @@ -117,6 +118,16 @@ resolved. If the issue returns to automation, request a Linear scan or let the n scheduled scan observe the corrected tracker state; do not bypass the guardrail with a manual retry that leaves the same evidence unchanged. +For authority-boundary decision requests, the supported resume sequence is: + +1. Record the decision in the issue, accepted Decision Contract, or project policy. +2. Clear or preserve `decodex:needs-attention` according to that decision. +3. Requeue or resume through supported Decodex lifecycle controls only after the + status/evidence tuple still matches the retained lane. + +Do not resume by editing raw tracker records, mutating runtime SQLite directly, or +referring to internal graph ids as the operator-facing decision. + ## Broad Steer Examples Broad steer can be delivered by the runtime, but it does not erase lifecycle authority. diff --git a/docs/spec/agent-evidence.md b/docs/spec/agent-evidence.md index e67df85c0..0ecc4ccac 100644 --- a/docs/spec/agent-evidence.md +++ b/docs/spec/agent-evidence.md @@ -160,6 +160,11 @@ The readback includes: 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. +- Authority decision request summaries when `authority_decision_request` events are + present. Default readback may expose the decision request id, phase, reason, + boundary, recommendation, resume condition, and next action, but not retained + worktree evidence, raw diff payloads, recovery context, transcripts, logs, or + credentials unless `--include-payload` is explicitly requested for local repair. - 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 @@ -172,10 +177,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. +Authority Boundary Check candidates and authority decision request summaries are +readback surfaces. They may recommend Decision Contract, issue-template, validator, +prompt, readiness-model, or issue-authority hardening when recovery evidence shows +underspecified authority. They do not change queue eligibility, accepted Decision +Contracts, or project policy by themselves. ## Event Stream diff --git a/docs/spec/loop-runtime.md b/docs/spec/loop-runtime.md index a19c877b9..3c362c6e6 100644 --- a/docs/spec/loop-runtime.md +++ b/docs/spec/loop-runtime.md @@ -205,6 +205,31 @@ briefs, and ordinary operator summaries may expose only coarse reason codes or n actions rendered by allowlisted lifecycle paths. They must not mirror raw changed surfaces, graph ids, transcript text, or private recovery payloads. +When the final disposition is `requires_human`, detached lanes must create a durable +decision request instead of asking a transient Codex chat. The private payload is +versioned as `decodex.authority_decision_request/1` with +`event_type = "authority_decision_request"` in `private_execution_events`. It links +the issue id, issue identifier, run id, attempt number, Authority Boundary Check +record id, retained worktree or diff evidence, and recovery-attempt context. It also +stores the public-safe decision request id, reason code, boundary type, proposed +change, why the change exceeds accepted authority, options, recommendation, resume +condition, and `phase = "human_required"`. + +The matching Linear projection must add or preserve `decodex:needs-attention` and +write an allowlisted `manual_attention` comment with the public-safe request fields. +It must not expose internal graph ids, host-local paths, raw diffs, credentials, +transcripts, logs, or sensitive runtime payloads. Operator status and dashboard +snapshots surface the request as `phase = human_required`, the boundary reason, +boundary type, `decision_request_id`, and `next_action` so operators can find the +decision without SQLite inspection. + +A decision request is resolved only by an explicit issue update, Decision Contract +update, or supported policy update that accepts, rejects, or revises the proposed +direction. After that deliberate decision, an operator may clear +`decodex:needs-attention` and requeue or resume through normal Decodex lifecycle +controls. Raw tracker mutation, direct database edits, and internal graph ids are not +supported resume mechanisms. + 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 diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 4fc4dde00..64d40bc7f 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -462,6 +462,14 @@ currently kind `manual_attention`, so the Linear-visible summary is rendered fro structured public fields instead of an arbitrary agent-authored body. Private command or error details must remain in local runtime evidence when they cannot pass the public-text guard. +For authority-boundary stops, the same path must include a durable decision request: +the public comment carries the reason code, boundary type, proposed change, why it +exceeds accepted authority, options, recommendation, and resume condition, while the +full `decodex.authority_decision_request/1` packet stays in private execution events +linked to the Authority Boundary Check record. Status JSON and dashboard snapshots +must expose the compact request fields (`phase = human_required`, reason, boundary, +`decision_request_id`, and `next_action`) so the lane is operable without inspecting +SQLite directly. Runtime-owned review-policy stops use the same human-required failure path, but with dedicated `error_class` values: - `review_policy_exhausted` @@ -494,6 +502,11 @@ must consume the latest Authority Boundary Check or record a fresh one before ch 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. +The supported resume path is deliberate: accept, reject, or revise the requested +authority change in the issue, Decision Contract, or project policy; then clear +`decodex:needs-attention` and requeue or resume through Decodex controls. Direct +tracker mutation, database edits, and internal graph ids are not valid resume +interfaces. 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. diff --git a/docs/spec/tracker-tools.md b/docs/spec/tracker-tools.md index d61dc9b14..bfaa91737 100644 --- a/docs/spec/tracker-tools.md +++ b/docs/spec/tracker-tools.md @@ -186,6 +186,14 @@ In either invalid case, `decodex` must fail the attempt rather than infer which values are public-safe. The tool must reject private-looking command or error text before any Linear mutation. The agent must not speculate about capabilities or environment restrictions that it did not directly verify. +- For authority-boundary stops, `manual_attention` may include a structured + `decision_request` object. Its Linear-rendered fields are public-safe only: + `decision_request_id`, `reason_code`, `boundary_type`, `proposed_change`, + `why_exceeds_authority`, options, recommendation, and `resume_condition`. Its + private fields, including the Authority Boundary Check record id, retained + worktree evidence, retained diff evidence, and recovery-attempt context, must be + written to private runtime evidence before the Linear write and must not be + rendered into the public comment. - The human-attention exit is not complete until the explanatory comment is successfully written after the label request. A label-only signal must be rejected as an invalid completion disposition. - The run is not complete until `issue_terminal_finalize` succeeds against the matching terminal path. An execution-state checkpoint or an agent summary message is not a substitute. - Issues that carry the configured `needs_attention_label` must remain ineligible for future automatic selection until a human clears the label. From 8b0843bbad13997d4eed1efceadbf4a0d5bb2005 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 10 Jun 2026 17:27:17 +0800 Subject: [PATCH 2/2] {"schema":"decodex/commit/1","summary":"format tracker bridge imports","authority":"XY-877"} --- .../decodex/src/agent/tracker_tool_bridge/tools.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs index 8dedbd485..d559aed59 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs @@ -1,6 +1,7 @@ use serde_json::{self, Value}; -use crate::{agent::tracker_tool_bridge::{ +use crate::{ + agent::tracker_tool_bridge::{ self, AuthorityDecisionOptionArgs, AuthorityDecisionRequestArgs, CommentArgs, DynamicToolCallResponse, DynamicToolSpec, ExecutionProgressPhase, ISSUE_COMMENT_TOOL_NAME, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, @@ -13,10 +14,17 @@ use crate::{agent::tracker_tool_bridge::{ ReviewCheckpointRejectedFindingArgs, ReviewExecutionMode, ReviewHandoffArgs, ReviewHandoffContext, ReviewPolicyPhase, ReviewPolicyStatus, RunCompletionDisposition, TerminalFinalizeArgs, TrackerToolBridge, TransitionArgs, - }, orchestrator::{self, AuthorityDecisionOption, AuthorityDecisionRequestInput, AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE}, state::{self, StateStore}, tracker::{ + }, + orchestrator::{ + self, AUTHORITY_BOUNDARY_CHECK_EVENT_TYPE, AuthorityDecisionOption, + AuthorityDecisionRequestInput, + }, + state::{self, StateStore}, + tracker::{ self, public_text, records, records::{LinearExecutionEventIdentity, LinearExecutionEventRecord}, - }}; + }, +}; const COMMENT_KIND_MANUAL_ATTENTION: &str = "manual_attention"; const MANUAL_ATTENTION_TERMINAL_PATH: &str = "manual_attention";