Skip to main content

oris_agent_contract/
lib.rs

1//! Proposal-only runtime contract for external agents.
2
3use serde::{Deserialize, Serialize};
4
5pub const A2A_PROTOCOL_NAME: &str = "oris.a2a";
6pub const A2A_PROTOCOL_VERSION: &str = "0.1.0-experimental";
7pub const A2A_PROTOCOL_VERSION_V1: &str = "1.0.0";
8pub const A2A_SUPPORTED_PROTOCOL_VERSIONS: [&str; 2] =
9    [A2A_PROTOCOL_VERSION_V1, A2A_PROTOCOL_VERSION];
10
11#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
12pub struct A2aProtocol {
13    pub name: String,
14    pub version: String,
15}
16
17impl A2aProtocol {
18    pub fn current() -> Self {
19        Self {
20            name: A2A_PROTOCOL_NAME.to_string(),
21            version: A2A_PROTOCOL_VERSION.to_string(),
22        }
23    }
24}
25
26#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
27pub enum A2aCapability {
28    Coordination,
29    MutationProposal,
30    ReplayFeedback,
31    SupervisedDevloop,
32    EvolutionPublish,
33    EvolutionFetch,
34    EvolutionRevoke,
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
38pub struct A2aHandshakeRequest {
39    pub agent_id: String,
40    pub role: AgentRole,
41    pub capability_level: AgentCapabilityLevel,
42    pub supported_protocols: Vec<A2aProtocol>,
43    pub advertised_capabilities: Vec<A2aCapability>,
44}
45
46impl A2aHandshakeRequest {
47    pub fn supports_protocol_version(&self, version: &str) -> bool {
48        self.supported_protocols
49            .iter()
50            .any(|protocol| protocol.name == A2A_PROTOCOL_NAME && protocol.version == version)
51    }
52
53    pub fn supports_current_protocol(&self) -> bool {
54        self.supports_protocol_version(A2A_PROTOCOL_VERSION)
55    }
56
57    pub fn negotiate_supported_protocol(&self) -> Option<A2aProtocol> {
58        for version in A2A_SUPPORTED_PROTOCOL_VERSIONS {
59            if self.supports_protocol_version(version) {
60                return Some(A2aProtocol {
61                    name: A2A_PROTOCOL_NAME.to_string(),
62                    version: version.to_string(),
63                });
64            }
65        }
66        None
67    }
68}
69
70#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
71pub struct A2aHandshakeResponse {
72    pub accepted: bool,
73    pub negotiated_protocol: Option<A2aProtocol>,
74    pub enabled_capabilities: Vec<A2aCapability>,
75    pub message: Option<String>,
76    pub error: Option<A2aErrorEnvelope>,
77}
78
79impl A2aHandshakeResponse {
80    pub fn accept(enabled_capabilities: Vec<A2aCapability>) -> Self {
81        Self {
82            accepted: true,
83            negotiated_protocol: Some(A2aProtocol::current()),
84            enabled_capabilities,
85            message: Some("handshake accepted".to_string()),
86            error: None,
87        }
88    }
89
90    pub fn reject(code: A2aErrorCode, message: impl Into<String>, details: Option<String>) -> Self {
91        Self {
92            accepted: false,
93            negotiated_protocol: None,
94            enabled_capabilities: Vec::new(),
95            message: Some("handshake rejected".to_string()),
96            error: Some(A2aErrorEnvelope {
97                code,
98                message: message.into(),
99                retriable: true,
100                details,
101            }),
102        }
103    }
104}
105
106#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
107pub enum A2aTaskLifecycleState {
108    Queued,
109    Running,
110    Succeeded,
111    Failed,
112    Cancelled,
113}
114
115#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
116pub struct A2aTaskLifecycleEvent {
117    pub task_id: String,
118    pub state: A2aTaskLifecycleState,
119    pub summary: String,
120    pub updated_at_ms: u64,
121    pub error: Option<A2aErrorEnvelope>,
122}
123
124pub const A2A_TASK_SESSION_PROTOCOL_VERSION: &str = A2A_PROTOCOL_VERSION;
125
126#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
127pub enum A2aTaskSessionState {
128    Started,
129    Dispatched,
130    InProgress,
131    Completed,
132    Failed,
133    Cancelled,
134}
135
136#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
137pub struct A2aTaskSessionStartRequest {
138    pub sender_id: String,
139    pub protocol_version: String,
140    pub task_id: String,
141    pub task_summary: String,
142}
143
144#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
145pub struct A2aTaskSessionDispatchRequest {
146    pub sender_id: String,
147    pub protocol_version: String,
148    pub dispatch_id: String,
149    pub summary: String,
150}
151
152#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
153pub struct A2aTaskSessionProgressRequest {
154    pub sender_id: String,
155    pub protocol_version: String,
156    pub progress_pct: u8,
157    pub summary: String,
158    pub retryable: bool,
159    pub retry_after_ms: Option<u64>,
160}
161
162#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
163pub struct A2aTaskSessionCompletionRequest {
164    pub sender_id: String,
165    pub protocol_version: String,
166    pub terminal_state: A2aTaskLifecycleState,
167    pub summary: String,
168    pub retryable: bool,
169    pub retry_after_ms: Option<u64>,
170    pub failure_code: Option<A2aErrorCode>,
171    pub failure_details: Option<String>,
172    pub used_capsule: bool,
173    pub capsule_id: Option<String>,
174    pub reasoning_steps_avoided: u64,
175    pub fallback_reason: Option<String>,
176    pub reason_code: Option<ReplayFallbackReasonCode>,
177    pub repair_hint: Option<String>,
178    pub next_action: Option<ReplayFallbackNextAction>,
179    pub confidence: Option<u8>,
180    pub task_class_id: String,
181    pub task_label: String,
182}
183
184#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
185pub struct A2aTaskSessionProgressItem {
186    pub progress_pct: u8,
187    pub summary: String,
188    pub retryable: bool,
189    pub retry_after_ms: Option<u64>,
190    pub updated_at_ms: u64,
191}
192
193#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
194pub struct A2aTaskSessionAck {
195    pub session_id: String,
196    pub task_id: String,
197    pub state: A2aTaskSessionState,
198    pub summary: String,
199    pub retryable: bool,
200    pub retry_after_ms: Option<u64>,
201    pub updated_at_ms: u64,
202}
203
204#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
205pub struct A2aTaskSessionResult {
206    pub terminal_state: A2aTaskLifecycleState,
207    pub summary: String,
208    pub retryable: bool,
209    pub retry_after_ms: Option<u64>,
210    pub failure_code: Option<A2aErrorCode>,
211    pub failure_details: Option<String>,
212    pub replay_feedback: ReplayFeedback,
213}
214
215#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
216pub struct A2aTaskSessionCompletionResponse {
217    pub ack: A2aTaskSessionAck,
218    pub result: A2aTaskSessionResult,
219}
220
221#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
222pub struct A2aTaskSessionSnapshot {
223    pub session_id: String,
224    pub sender_id: String,
225    pub task_id: String,
226    pub protocol_version: String,
227    pub state: A2aTaskSessionState,
228    pub created_at_ms: u64,
229    pub updated_at_ms: u64,
230    pub dispatch_ids: Vec<String>,
231    pub progress: Vec<A2aTaskSessionProgressItem>,
232    pub result: Option<A2aTaskSessionResult>,
233}
234
235#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
236pub enum A2aErrorCode {
237    UnsupportedProtocol,
238    UnsupportedCapability,
239    ValidationFailed,
240    AuthorizationDenied,
241    Timeout,
242    Internal,
243}
244
245#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
246pub struct A2aErrorEnvelope {
247    pub code: A2aErrorCode,
248    pub message: String,
249    pub retriable: bool,
250    pub details: Option<String>,
251}
252
253#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
254pub enum AgentCapabilityLevel {
255    A0,
256    A1,
257    A2,
258    A3,
259    A4,
260}
261
262#[derive(Clone, Debug, Serialize, Deserialize)]
263pub enum ProposalTarget {
264    WorkspaceRoot,
265    Paths(Vec<String>),
266}
267
268#[derive(Clone, Debug, Serialize, Deserialize)]
269pub struct AgentTask {
270    pub id: String,
271    pub description: String,
272}
273
274#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
275pub enum AgentRole {
276    Planner,
277    Coder,
278    Repair,
279    Optimizer,
280}
281
282#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
283pub enum CoordinationPrimitive {
284    Sequential,
285    Parallel,
286    Conditional,
287}
288
289#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
290pub struct CoordinationTask {
291    pub id: String,
292    pub role: AgentRole,
293    pub description: String,
294    pub depends_on: Vec<String>,
295}
296
297#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
298pub struct CoordinationMessage {
299    pub from_role: AgentRole,
300    pub to_role: AgentRole,
301    pub task_id: String,
302    pub content: String,
303}
304
305#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
306pub struct CoordinationPlan {
307    pub root_goal: String,
308    pub primitive: CoordinationPrimitive,
309    pub tasks: Vec<CoordinationTask>,
310    pub timeout_ms: u64,
311    pub max_retries: u32,
312}
313
314#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
315pub struct CoordinationResult {
316    pub completed_tasks: Vec<String>,
317    pub failed_tasks: Vec<String>,
318    pub messages: Vec<CoordinationMessage>,
319    pub summary: String,
320}
321
322#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
323pub struct MutationProposal {
324    pub intent: String,
325    pub files: Vec<String>,
326    pub expected_effect: String,
327}
328
329#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
330#[serde(rename_all = "snake_case")]
331pub enum MutationProposalContractReasonCode {
332    Accepted,
333    CandidateRejected,
334    MissingTargetFiles,
335    OutOfBoundsPath,
336    UnsupportedTaskClass,
337    ValidationBudgetExceeded,
338    ExpectedEvidenceMissing,
339    UnknownFailClosed,
340}
341
342#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
343#[serde(rename_all = "snake_case")]
344pub enum MutationProposalEvidence {
345    HumanApproval,
346    BoundedScope,
347    ValidationPass,
348    ExecutionAudit,
349}
350
351#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
352pub struct MutationProposalValidationBudget {
353    pub max_diff_bytes: usize,
354    pub max_changed_lines: usize,
355    pub validation_timeout_ms: u64,
356}
357
358#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
359pub struct ExecutionFeedback {
360    pub accepted: bool,
361    pub asset_state: Option<String>,
362    pub summary: String,
363}
364
365#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
366pub enum ReplayPlannerDirective {
367    SkipPlanner,
368    PlanFallback,
369}
370
371#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
372#[serde(rename_all = "snake_case")]
373pub enum ReplayFallbackReasonCode {
374    NoCandidateAfterSelect,
375    ScoreBelowThreshold,
376    CandidateHasNoCapsule,
377    MutationPayloadMissing,
378    PatchApplyFailed,
379    ValidationFailed,
380    UnmappedFallbackReason,
381}
382
383#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
384#[serde(rename_all = "snake_case")]
385pub enum ReplayFallbackNextAction {
386    PlanFromScratch,
387    ValidateSignalsThenPlan,
388    RebuildCapsule,
389    RegenerateMutationPayload,
390    RebasePatchAndRetry,
391    RepairAndRevalidate,
392    EscalateFailClosed,
393}
394
395#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
396pub struct ReplayFallbackContract {
397    pub reason_code: ReplayFallbackReasonCode,
398    pub fallback_reason: String,
399    pub repair_hint: String,
400    pub next_action: ReplayFallbackNextAction,
401    /// Confidence score in [0, 100].
402    pub confidence: u8,
403}
404
405pub fn infer_replay_fallback_reason_code(reason: &str) -> Option<ReplayFallbackReasonCode> {
406    let normalized = reason.trim().to_ascii_lowercase();
407    if normalized.is_empty() {
408        return None;
409    }
410    if normalized == "no_candidate_after_select" || normalized.contains("no matching gene") {
411        return Some(ReplayFallbackReasonCode::NoCandidateAfterSelect);
412    }
413    if normalized == "score_below_threshold" || normalized.contains("below replay threshold") {
414        return Some(ReplayFallbackReasonCode::ScoreBelowThreshold);
415    }
416    if normalized == "candidate_has_no_capsule" || normalized.contains("has no capsule") {
417        return Some(ReplayFallbackReasonCode::CandidateHasNoCapsule);
418    }
419    if normalized == "mutation_payload_missing" || normalized.contains("payload missing") {
420        return Some(ReplayFallbackReasonCode::MutationPayloadMissing);
421    }
422    if normalized == "patch_apply_failed" || normalized.contains("patch apply failed") {
423        return Some(ReplayFallbackReasonCode::PatchApplyFailed);
424    }
425    if normalized == "validation_failed" || normalized.contains("validation failed") {
426        return Some(ReplayFallbackReasonCode::ValidationFailed);
427    }
428    None
429}
430
431pub fn normalize_replay_fallback_contract(
432    planner_directive: &ReplayPlannerDirective,
433    fallback_reason: Option<&str>,
434    reason_code: Option<ReplayFallbackReasonCode>,
435    repair_hint: Option<&str>,
436    next_action: Option<ReplayFallbackNextAction>,
437    confidence: Option<u8>,
438) -> Option<ReplayFallbackContract> {
439    if !matches!(planner_directive, ReplayPlannerDirective::PlanFallback) {
440        return None;
441    }
442
443    let normalized_reason = normalize_optional_text(fallback_reason);
444    let normalized_repair_hint = normalize_optional_text(repair_hint);
445    let mut resolved_reason_code = reason_code
446        .or_else(|| {
447            normalized_reason
448                .as_deref()
449                .and_then(infer_replay_fallback_reason_code)
450        })
451        .unwrap_or(ReplayFallbackReasonCode::UnmappedFallbackReason);
452    let mut defaults = replay_fallback_defaults(&resolved_reason_code);
453
454    let mut force_fail_closed = false;
455    if let Some(provided_action) = next_action {
456        if provided_action != defaults.next_action {
457            resolved_reason_code = ReplayFallbackReasonCode::UnmappedFallbackReason;
458            defaults = replay_fallback_defaults(&resolved_reason_code);
459            force_fail_closed = true;
460        }
461    }
462
463    Some(ReplayFallbackContract {
464        reason_code: resolved_reason_code,
465        fallback_reason: normalized_reason.unwrap_or_else(|| defaults.fallback_reason.to_string()),
466        repair_hint: normalized_repair_hint.unwrap_or_else(|| defaults.repair_hint.to_string()),
467        next_action: if force_fail_closed {
468            defaults.next_action
469        } else {
470            next_action.unwrap_or(defaults.next_action)
471        },
472        confidence: if force_fail_closed {
473            defaults.confidence
474        } else {
475            confidence.unwrap_or(defaults.confidence).min(100)
476        },
477    })
478}
479
480#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
481pub struct ReplayFeedback {
482    pub used_capsule: bool,
483    pub capsule_id: Option<String>,
484    pub planner_directive: ReplayPlannerDirective,
485    pub reasoning_steps_avoided: u64,
486    pub fallback_reason: Option<String>,
487    pub reason_code: Option<ReplayFallbackReasonCode>,
488    pub repair_hint: Option<String>,
489    pub next_action: Option<ReplayFallbackNextAction>,
490    pub confidence: Option<u8>,
491    pub task_class_id: String,
492    pub task_label: String,
493    pub summary: String,
494}
495
496#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
497#[serde(rename_all = "snake_case")]
498pub enum MutationNeededFailureReasonCode {
499    PolicyDenied,
500    ValidationFailed,
501    UnsafePatch,
502    Timeout,
503    MutationPayloadMissing,
504    UnknownFailClosed,
505}
506
507#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
508#[serde(rename_all = "snake_case")]
509pub enum MutationNeededRecoveryAction {
510    NarrowScopeAndRetry,
511    RepairAndRevalidate,
512    ProduceSafePatch,
513    ReduceExecutionBudget,
514    RegenerateMutationPayload,
515    EscalateFailClosed,
516}
517
518#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
519pub struct MutationNeededFailureContract {
520    pub reason_code: MutationNeededFailureReasonCode,
521    pub failure_reason: String,
522    pub recovery_hint: String,
523    pub recovery_action: MutationNeededRecoveryAction,
524    pub fail_closed: bool,
525}
526
527pub fn infer_mutation_needed_failure_reason_code(
528    reason: &str,
529) -> Option<MutationNeededFailureReasonCode> {
530    let normalized = reason.trim().to_ascii_lowercase();
531    if normalized.is_empty() {
532        return None;
533    }
534    if normalized.contains("mutation payload missing") || normalized == "mutation_payload_missing" {
535        return Some(MutationNeededFailureReasonCode::MutationPayloadMissing);
536    }
537    if normalized.contains("command timed out") || normalized.contains(" timeout") {
538        return Some(MutationNeededFailureReasonCode::Timeout);
539    }
540    if normalized.contains("patch rejected")
541        || normalized.contains("patch apply failed")
542        || normalized.contains("target violation")
543        || normalized.contains("unsafe patch")
544    {
545        return Some(MutationNeededFailureReasonCode::UnsafePatch);
546    }
547    if normalized.contains("validation failed") {
548        return Some(MutationNeededFailureReasonCode::ValidationFailed);
549    }
550    if normalized.contains("command denied by policy")
551        || normalized.contains("rejected task")
552        || normalized.contains("unsupported task outside the bounded scope")
553        || normalized.contains("budget exceeds bounded policy")
554    {
555        return Some(MutationNeededFailureReasonCode::PolicyDenied);
556    }
557    None
558}
559
560pub fn normalize_mutation_needed_failure_contract(
561    failure_reason: Option<&str>,
562    reason_code: Option<MutationNeededFailureReasonCode>,
563) -> MutationNeededFailureContract {
564    let normalized_reason = normalize_optional_text(failure_reason);
565    let resolved_reason_code = reason_code
566        .or_else(|| {
567            normalized_reason
568                .as_deref()
569                .and_then(infer_mutation_needed_failure_reason_code)
570        })
571        .unwrap_or(MutationNeededFailureReasonCode::UnknownFailClosed);
572    let defaults = mutation_needed_failure_defaults(&resolved_reason_code);
573
574    MutationNeededFailureContract {
575        reason_code: resolved_reason_code,
576        failure_reason: normalized_reason.unwrap_or_else(|| defaults.failure_reason.to_string()),
577        recovery_hint: defaults.recovery_hint.to_string(),
578        recovery_action: defaults.recovery_action,
579        fail_closed: true,
580    }
581}
582
583fn normalize_optional_text(value: Option<&str>) -> Option<String> {
584    let trimmed = value?.trim();
585    if trimmed.is_empty() {
586        None
587    } else {
588        Some(trimmed.to_string())
589    }
590}
591
592#[derive(Clone, Copy)]
593struct ReplayFallbackDefaults {
594    fallback_reason: &'static str,
595    repair_hint: &'static str,
596    next_action: ReplayFallbackNextAction,
597    confidence: u8,
598}
599
600fn replay_fallback_defaults(reason_code: &ReplayFallbackReasonCode) -> ReplayFallbackDefaults {
601    match reason_code {
602        ReplayFallbackReasonCode::NoCandidateAfterSelect => ReplayFallbackDefaults {
603            fallback_reason: "no matching gene",
604            repair_hint:
605                "No reusable capsule matched deterministic signals; run planner for a minimal patch.",
606            next_action: ReplayFallbackNextAction::PlanFromScratch,
607            confidence: 92,
608        },
609        ReplayFallbackReasonCode::ScoreBelowThreshold => ReplayFallbackDefaults {
610            fallback_reason: "candidate score below replay threshold",
611            repair_hint:
612                "Best replay candidate is below threshold; validate task signals and re-plan.",
613            next_action: ReplayFallbackNextAction::ValidateSignalsThenPlan,
614            confidence: 86,
615        },
616        ReplayFallbackReasonCode::CandidateHasNoCapsule => ReplayFallbackDefaults {
617            fallback_reason: "candidate gene has no capsule",
618            repair_hint: "Matched gene has no executable capsule; rebuild capsule from planner output.",
619            next_action: ReplayFallbackNextAction::RebuildCapsule,
620            confidence: 80,
621        },
622        ReplayFallbackReasonCode::MutationPayloadMissing => ReplayFallbackDefaults {
623            fallback_reason: "mutation payload missing from store",
624            repair_hint:
625                "Mutation payload is missing; regenerate and persist a minimal mutation payload.",
626            next_action: ReplayFallbackNextAction::RegenerateMutationPayload,
627            confidence: 76,
628        },
629        ReplayFallbackReasonCode::PatchApplyFailed => ReplayFallbackDefaults {
630            fallback_reason: "replay patch apply failed",
631            repair_hint: "Replay patch cannot be applied cleanly; rebase patch and retry planning.",
632            next_action: ReplayFallbackNextAction::RebasePatchAndRetry,
633            confidence: 68,
634        },
635        ReplayFallbackReasonCode::ValidationFailed => ReplayFallbackDefaults {
636            fallback_reason: "replay validation failed",
637            repair_hint: "Replay validation failed; produce a repair mutation and re-run validation.",
638            next_action: ReplayFallbackNextAction::RepairAndRevalidate,
639            confidence: 64,
640        },
641        ReplayFallbackReasonCode::UnmappedFallbackReason => ReplayFallbackDefaults {
642            fallback_reason: "unmapped replay fallback reason",
643            repair_hint:
644                "Fallback reason is unmapped; fail closed and require explicit planner intervention.",
645            next_action: ReplayFallbackNextAction::EscalateFailClosed,
646            confidence: 0,
647        },
648    }
649}
650
651#[derive(Clone, Copy)]
652struct MutationNeededFailureDefaults {
653    failure_reason: &'static str,
654    recovery_hint: &'static str,
655    recovery_action: MutationNeededRecoveryAction,
656}
657
658fn mutation_needed_failure_defaults(
659    reason_code: &MutationNeededFailureReasonCode,
660) -> MutationNeededFailureDefaults {
661    match reason_code {
662        MutationNeededFailureReasonCode::PolicyDenied => MutationNeededFailureDefaults {
663            failure_reason: "mutation needed denied by bounded execution policy",
664            recovery_hint:
665                "Narrow changed scope to the approved docs boundary and re-run with explicit approval.",
666            recovery_action: MutationNeededRecoveryAction::NarrowScopeAndRetry,
667        },
668        MutationNeededFailureReasonCode::ValidationFailed => MutationNeededFailureDefaults {
669            failure_reason: "mutation needed validation failed",
670            recovery_hint:
671                "Repair mutation and re-run validation to produce a deterministic pass before capture.",
672            recovery_action: MutationNeededRecoveryAction::RepairAndRevalidate,
673        },
674        MutationNeededFailureReasonCode::UnsafePatch => MutationNeededFailureDefaults {
675            failure_reason: "mutation needed rejected unsafe patch",
676            recovery_hint:
677                "Generate a safer minimal diff confined to approved paths and verify patch applicability.",
678            recovery_action: MutationNeededRecoveryAction::ProduceSafePatch,
679        },
680        MutationNeededFailureReasonCode::Timeout => MutationNeededFailureDefaults {
681            failure_reason: "mutation needed execution timed out",
682            recovery_hint:
683                "Reduce execution budget or split the mutation into smaller steps before retrying.",
684            recovery_action: MutationNeededRecoveryAction::ReduceExecutionBudget,
685        },
686        MutationNeededFailureReasonCode::MutationPayloadMissing => MutationNeededFailureDefaults {
687            failure_reason: "mutation payload missing from store",
688            recovery_hint: "Regenerate and persist mutation payload before retrying mutation-needed.",
689            recovery_action: MutationNeededRecoveryAction::RegenerateMutationPayload,
690        },
691        MutationNeededFailureReasonCode::UnknownFailClosed => MutationNeededFailureDefaults {
692            failure_reason: "mutation needed failed with unmapped reason",
693            recovery_hint:
694                "Unknown failure class; fail closed and require explicit maintainer triage before retry.",
695            recovery_action: MutationNeededRecoveryAction::EscalateFailClosed,
696        },
697    }
698}
699
700#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
701pub enum BoundedTaskClass {
702    DocsSingleFile,
703    DocsMultiFile,
704    /// Dependency version bump: restricted to `Cargo.toml` / `Cargo.lock`
705    /// paths, version fields only, max 5 manifests.
706    CargoDepUpgrade,
707    /// Lint / formatting fix: auto-fixable `cargo fmt` or `cargo clippy --fix`
708    /// changes, no logic modifications, max 5 source files.
709    LintFix,
710}
711
712#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
713pub struct MutationProposalScope {
714    pub task_class: BoundedTaskClass,
715    pub target_files: Vec<String>,
716}
717
718#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
719pub struct SelfEvolutionMutationProposalContract {
720    pub mutation_proposal: MutationProposal,
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub proposal_scope: Option<MutationProposalScope>,
723    pub validation_budget: MutationProposalValidationBudget,
724    pub approval_required: bool,
725    pub expected_evidence: Vec<MutationProposalEvidence>,
726    pub summary: String,
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub failure_reason: Option<String>,
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub recovery_hint: Option<String>,
731    pub reason_code: MutationProposalContractReasonCode,
732    pub fail_closed: bool,
733}
734
735#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
736pub struct SelfEvolutionCandidateIntakeRequest {
737    pub issue_number: u64,
738    pub title: String,
739    pub body: String,
740    #[serde(default)]
741    pub labels: Vec<String>,
742    pub state: String,
743    #[serde(default)]
744    pub candidate_hint_paths: Vec<String>,
745}
746
747/// Signal source for an autonomously discovered candidate.
748#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
749#[serde(rename_all = "snake_case")]
750pub enum AutonomousCandidateSource {
751    CiFailure,
752    TestRegression,
753    CompileRegression,
754    LintRegression,
755    RuntimeIncident,
756}
757
758/// Reason code for the outcome of autonomous candidate classification.
759#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
760#[serde(rename_all = "snake_case")]
761pub enum AutonomousIntakeReasonCode {
762    Accepted,
763    UnsupportedSignalClass,
764    AmbiguousSignal,
765    DuplicateCandidate,
766    UnknownFailClosed,
767}
768
769/// A candidate discovered autonomously from CI or runtime signals without
770/// a caller-supplied issue number.
771#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
772pub struct DiscoveredCandidate {
773    /// Stable identity hash, deterministic for the same raw signals.
774    pub dedupe_key: String,
775    /// Classified signal source.
776    pub candidate_source: AutonomousCandidateSource,
777    /// Normalised candidate class (reuses `BoundedTaskClass`).
778    pub candidate_class: Option<BoundedTaskClass>,
779    /// Normalised signal tokens used as the discovered work description.
780    pub signals: Vec<String>,
781    /// Whether this candidate was accepted for further work.
782    pub accepted: bool,
783    /// Outcome reason code.
784    pub reason_code: AutonomousIntakeReasonCode,
785    /// Human-readable summary.
786    pub summary: String,
787    #[serde(default, skip_serializing_if = "Option::is_none")]
788    pub failure_reason: Option<String>,
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub recovery_hint: Option<String>,
791    /// Fail-closed flag: true on any non-accepted outcome.
792    pub fail_closed: bool,
793}
794
795/// Input for autonomous candidate discovery from raw diagnostic output.
796#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
797pub struct AutonomousIntakeInput {
798    /// Raw source identifier (e.g. CI run ID, log stream name).
799    pub source_id: String,
800    /// Classified origin of the raw signals.
801    pub candidate_source: AutonomousCandidateSource,
802    /// Raw text lines from diagnostics, test output, or incident logs.
803    pub raw_signals: Vec<String>,
804}
805
806/// Output of autonomous candidate intake: one or more discovered candidates
807/// (deduplicated) plus any that were denied with fail-closed reason codes.
808#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
809pub struct AutonomousIntakeOutput {
810    pub candidates: Vec<DiscoveredCandidate>,
811    pub accepted_count: usize,
812    pub denied_count: usize,
813}
814
815// ── AUTO-02: Bounded Task Planning and Risk Scoring ──────────────────────────
816
817/// Risk tier assigned to an autonomous task plan.
818#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
819#[serde(rename_all = "snake_case")]
820pub enum AutonomousRiskTier {
821    /// Minimal blast radius, fully reversible, single-file scope.
822    Low,
823    /// Multi-file scope or non-trivial dependency changes.
824    Medium,
825    /// High blast radius, wide impact, or unknown effect on public API.
826    High,
827}
828
829/// Reason code for the outcome of autonomous task planning.
830#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
831#[serde(rename_all = "snake_case")]
832pub enum AutonomousPlanReasonCode {
833    Approved,
834    DeniedHighRisk,
835    DeniedLowFeasibility,
836    DeniedUnsupportedClass,
837    DeniedNoEvidence,
838    UnknownFailClosed,
839}
840
841/// A denial condition attached to a rejected autonomous task plan.
842#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
843pub struct AutonomousDenialCondition {
844    pub reason_code: AutonomousPlanReasonCode,
845    pub description: String,
846    pub recovery_hint: String,
847}
848
849/// An approved or denied autonomous task plan produced from a discovered candidate.
850#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
851pub struct AutonomousTaskPlan {
852    /// Stable identity derived from the originating `DiscoveredCandidate.dedupe_key`.
853    pub plan_id: String,
854    /// The input candidate this plan was derived from.
855    pub dedupe_key: String,
856    /// Normalised task class (same as candidate class when approved).
857    pub task_class: Option<BoundedTaskClass>,
858    /// Assigned risk tier.
859    pub risk_tier: AutonomousRiskTier,
860    /// Feasibility score in [0, 100]; 0 means not feasible.
861    pub feasibility_score: u8,
862    /// Estimated validation budget (number of validation stages required).
863    pub validation_budget: u8,
864    /// Evidence templates required for this plan class.
865    pub expected_evidence: Vec<String>,
866    /// Whether the plan was approved for proposal generation.
867    pub approved: bool,
868    /// Planning outcome reason code.
869    pub reason_code: AutonomousPlanReasonCode,
870    /// Short human-readable summary of the planning outcome.
871    pub summary: String,
872    /// Present when the plan was denied.
873    #[serde(default, skip_serializing_if = "Option::is_none")]
874    pub denial_condition: Option<AutonomousDenialCondition>,
875    /// Fail-closed flag: true on any non-approved outcome.
876    pub fail_closed: bool,
877}
878
879pub fn approve_autonomous_task_plan(
880    plan_id: impl Into<String>,
881    dedupe_key: impl Into<String>,
882    task_class: BoundedTaskClass,
883    risk_tier: AutonomousRiskTier,
884    feasibility_score: u8,
885    validation_budget: u8,
886    expected_evidence: Vec<String>,
887    summary: Option<&str>,
888) -> AutonomousTaskPlan {
889    let summary = normalize_optional_text(summary).unwrap_or_else(|| {
890        format!("autonomous task plan approved for {task_class:?} at {risk_tier:?} risk")
891    });
892    AutonomousTaskPlan {
893        plan_id: plan_id.into(),
894        dedupe_key: dedupe_key.into(),
895        task_class: Some(task_class),
896        risk_tier,
897        feasibility_score,
898        validation_budget,
899        expected_evidence,
900        approved: true,
901        reason_code: AutonomousPlanReasonCode::Approved,
902        summary,
903        denial_condition: None,
904        fail_closed: false,
905    }
906}
907
908pub fn deny_autonomous_task_plan(
909    plan_id: impl Into<String>,
910    dedupe_key: impl Into<String>,
911    risk_tier: AutonomousRiskTier,
912    reason_code: AutonomousPlanReasonCode,
913) -> AutonomousTaskPlan {
914    let (description, recovery_hint) = match reason_code {
915        AutonomousPlanReasonCode::DeniedHighRisk => (
916            "task plan denied because risk tier is too high for autonomous execution",
917            "reduce blast radius by scoping the change to a single bounded file before retrying",
918        ),
919        AutonomousPlanReasonCode::DeniedLowFeasibility => (
920            "task plan denied because feasibility score is below the policy threshold",
921            "provide stronger evidence or narrow the task scope before retrying",
922        ),
923        AutonomousPlanReasonCode::DeniedUnsupportedClass => (
924            "task plan denied because task class is not supported for autonomous planning",
925            "route this task class through the supervised planning path instead",
926        ),
927        AutonomousPlanReasonCode::DeniedNoEvidence => (
928            "task plan denied because no evidence was available to assess feasibility",
929            "ensure signals and candidate class are populated before planning",
930        ),
931        AutonomousPlanReasonCode::UnknownFailClosed => (
932            "task plan failed with an unmapped reason; fail closed",
933            "require explicit maintainer triage before retry",
934        ),
935        AutonomousPlanReasonCode::Approved => (
936            "unexpected approved reason on deny path",
937            "use approve_autonomous_task_plan for approved outcomes",
938        ),
939    };
940    let summary = format!("autonomous task plan denied [{reason_code:?}]: {description}");
941    AutonomousTaskPlan {
942        plan_id: plan_id.into(),
943        dedupe_key: dedupe_key.into(),
944        task_class: None,
945        risk_tier,
946        feasibility_score: 0,
947        validation_budget: 0,
948        expected_evidence: Vec::new(),
949        approved: false,
950        reason_code,
951        summary,
952        denial_condition: Some(AutonomousDenialCondition {
953            reason_code,
954            description: description.to_string(),
955            recovery_hint: recovery_hint.to_string(),
956        }),
957        fail_closed: true,
958    }
959}
960
961// ── AUTO-03: Autonomous mutation proposal contracts ───────────────────────────
962
963/// Approval mode for an autonomous mutation proposal.
964#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
965#[serde(rename_all = "snake_case")]
966pub enum AutonomousApprovalMode {
967    /// Proposal is self-approved within bounded policy; no human gate required.
968    AutoApproved,
969    /// Proposal requires explicit human review before execution.
970    RequiresHumanReview,
971}
972
973/// Stable reason code for an autonomous mutation proposal outcome.
974#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
975#[serde(rename_all = "snake_case")]
976pub enum AutonomousProposalReasonCode {
977    Proposed,
978    DeniedPlanNotApproved,
979    DeniedNoTargetScope,
980    DeniedWeakEvidence,
981    DeniedOutOfBounds,
982    UnknownFailClosed,
983}
984
985/// Bounded file scope for an autonomous mutation proposal.
986#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
987pub struct AutonomousProposalScope {
988    /// Target files or paths that may be mutated.
989    pub target_paths: Vec<String>,
990    /// Human-readable rationale for why these targets are in scope.
991    pub scope_rationale: String,
992    /// Maximum number of files that may be changed in this proposal.
993    pub max_files: u8,
994}
995
996/// A machine-readable mutation proposal generated from an approved autonomous plan.
997#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
998pub struct AutonomousMutationProposal {
999    /// Stable proposal identity hash.
1000    pub proposal_id: String,
1001    /// Matches `AutonomousTaskPlan.plan_id`.
1002    pub plan_id: String,
1003    /// Matches `AutonomousTaskPlan.dedupe_key`.
1004    pub dedupe_key: String,
1005    /// Bounded file scope for this mutation.
1006    pub scope: Option<AutonomousProposalScope>,
1007    /// Expected evidence templates (cargo commands, lint, tests, etc.).
1008    pub expected_evidence: Vec<String>,
1009    /// Conditions under which the mutation must be rolled back.
1010    pub rollback_conditions: Vec<String>,
1011    /// Approval mode for this proposal.
1012    pub approval_mode: AutonomousApprovalMode,
1013    /// Whether this proposal was successfully generated.
1014    pub proposed: bool,
1015    /// Stable outcome reason code.
1016    pub reason_code: AutonomousProposalReasonCode,
1017    /// Human-readable summary.
1018    pub summary: String,
1019    /// Structured denial information, populated when `proposed` is false.
1020    #[serde(default, skip_serializing_if = "Option::is_none")]
1021    pub denial_condition: Option<AutonomousDenialCondition>,
1022    /// True on any non-proposed outcome — enforces fail-closed semantics.
1023    pub fail_closed: bool,
1024}
1025
1026/// Construct an approved `AutonomousMutationProposal` from a valid plan.
1027pub fn approve_autonomous_mutation_proposal(
1028    proposal_id: impl Into<String>,
1029    plan_id: impl Into<String>,
1030    dedupe_key: impl Into<String>,
1031    scope: AutonomousProposalScope,
1032    expected_evidence: Vec<String>,
1033    rollback_conditions: Vec<String>,
1034    approval_mode: AutonomousApprovalMode,
1035    summary: Option<&str>,
1036) -> AutonomousMutationProposal {
1037    let dedupe_key = dedupe_key.into();
1038    let summary = summary
1039        .and_then(|s| {
1040            if s.trim().is_empty() {
1041                None
1042            } else {
1043                Some(s.to_string())
1044            }
1045        })
1046        .unwrap_or_else(|| format!("autonomous mutation proposal approved for {dedupe_key}"));
1047    AutonomousMutationProposal {
1048        proposal_id: proposal_id.into(),
1049        plan_id: plan_id.into(),
1050        dedupe_key,
1051        scope: Some(scope),
1052        expected_evidence,
1053        rollback_conditions,
1054        approval_mode,
1055        proposed: true,
1056        reason_code: AutonomousProposalReasonCode::Proposed,
1057        summary,
1058        denial_condition: None,
1059        fail_closed: false,
1060    }
1061}
1062
1063/// Construct a denied fail-closed `AutonomousMutationProposal`.
1064pub fn deny_autonomous_mutation_proposal(
1065    proposal_id: impl Into<String>,
1066    plan_id: impl Into<String>,
1067    dedupe_key: impl Into<String>,
1068    reason_code: AutonomousProposalReasonCode,
1069) -> AutonomousMutationProposal {
1070    let (description, recovery_hint) = match reason_code {
1071        AutonomousProposalReasonCode::DeniedPlanNotApproved => (
1072            "source plan was not approved; cannot generate proposal",
1073            "ensure the autonomous task plan is approved before proposing a mutation",
1074        ),
1075        AutonomousProposalReasonCode::DeniedNoTargetScope => (
1076            "no bounded target scope could be determined for this task class",
1077            "verify that the task class maps to a known set of target paths",
1078        ),
1079        AutonomousProposalReasonCode::DeniedWeakEvidence => (
1080            "the expected evidence set is empty or insufficient",
1081            "strengthen evidence requirements before reattempting proposal",
1082        ),
1083        AutonomousProposalReasonCode::DeniedOutOfBounds => (
1084            "proposal would mutate files outside of bounded policy scope",
1085            "restrict targets to explicitly allowed paths and retry",
1086        ),
1087        AutonomousProposalReasonCode::UnknownFailClosed => (
1088            "proposal generation failed with an unmapped reason; fail closed",
1089            "require explicit maintainer triage before retry",
1090        ),
1091        AutonomousProposalReasonCode::Proposed => (
1092            "unexpected proposed reason on deny path",
1093            "use approve_autonomous_mutation_proposal for proposed outcomes",
1094        ),
1095    };
1096    let dedupe_key = dedupe_key.into();
1097    let summary = format!("autonomous mutation proposal denied [{reason_code:?}]: {description}");
1098    AutonomousMutationProposal {
1099        proposal_id: proposal_id.into(),
1100        plan_id: plan_id.into(),
1101        dedupe_key,
1102        scope: None,
1103        expected_evidence: Vec::new(),
1104        rollback_conditions: Vec::new(),
1105        approval_mode: AutonomousApprovalMode::RequiresHumanReview,
1106        proposed: false,
1107        reason_code,
1108        summary,
1109        denial_condition: Some(AutonomousDenialCondition {
1110            reason_code: AutonomousPlanReasonCode::UnknownFailClosed,
1111            description: description.to_string(),
1112            recovery_hint: recovery_hint.to_string(),
1113        }),
1114        fail_closed: true,
1115    }
1116}
1117
1118#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1119#[serde(rename_all = "snake_case")]
1120pub enum SelfEvolutionSelectionReasonCode {
1121    Accepted,
1122    IssueClosed,
1123    MissingEvolutionLabel,
1124    MissingFeatureLabel,
1125    ExcludedByLabel,
1126    UnsupportedCandidateScope,
1127    UnknownFailClosed,
1128}
1129
1130#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1131pub struct SelfEvolutionSelectionDecision {
1132    pub issue_number: u64,
1133    pub selected: bool,
1134    #[serde(default, skip_serializing_if = "Option::is_none")]
1135    pub candidate_class: Option<BoundedTaskClass>,
1136    pub summary: String,
1137    #[serde(default, skip_serializing_if = "Option::is_none")]
1138    pub reason_code: Option<SelfEvolutionSelectionReasonCode>,
1139    #[serde(default, skip_serializing_if = "Option::is_none")]
1140    pub failure_reason: Option<String>,
1141    #[serde(default, skip_serializing_if = "Option::is_none")]
1142    pub recovery_hint: Option<String>,
1143    pub fail_closed: bool,
1144}
1145
1146#[derive(Clone, Copy)]
1147struct SelfEvolutionSelectionDefaults {
1148    failure_reason: &'static str,
1149    recovery_hint: &'static str,
1150}
1151
1152fn self_evolution_selection_defaults(
1153    reason_code: &SelfEvolutionSelectionReasonCode,
1154) -> Option<SelfEvolutionSelectionDefaults> {
1155    match reason_code {
1156        SelfEvolutionSelectionReasonCode::Accepted => None,
1157        SelfEvolutionSelectionReasonCode::IssueClosed => Some(SelfEvolutionSelectionDefaults {
1158            failure_reason: "self-evolution candidate rejected because the issue is closed",
1159            recovery_hint: "Reopen the issue or choose an active open issue before retrying selection.",
1160        }),
1161        SelfEvolutionSelectionReasonCode::MissingEvolutionLabel => {
1162            Some(SelfEvolutionSelectionDefaults {
1163                failure_reason: "self-evolution candidate rejected because the issue is missing area/evolution",
1164                recovery_hint:
1165                    "Add the area/evolution label or choose an issue already scoped to self-evolution.",
1166            })
1167        }
1168        SelfEvolutionSelectionReasonCode::MissingFeatureLabel => {
1169            Some(SelfEvolutionSelectionDefaults {
1170                failure_reason: "self-evolution candidate rejected because the issue is missing type/feature",
1171                recovery_hint:
1172                    "Add the type/feature label or narrow the issue to a bounded feature slice before retrying.",
1173            })
1174        }
1175        SelfEvolutionSelectionReasonCode::ExcludedByLabel => Some(SelfEvolutionSelectionDefaults {
1176            failure_reason: "self-evolution candidate rejected by an excluded issue label",
1177            recovery_hint:
1178                "Remove the excluded label or choose a non-duplicate, non-invalid, actionable issue.",
1179        }),
1180        SelfEvolutionSelectionReasonCode::UnsupportedCandidateScope => {
1181            Some(SelfEvolutionSelectionDefaults {
1182                failure_reason:
1183                    "self-evolution candidate rejected because the hinted file scope is outside the bounded docs policy",
1184                recovery_hint:
1185                    "Narrow candidate paths to the approved docs/*.md boundary before retrying selection.",
1186            })
1187        }
1188        SelfEvolutionSelectionReasonCode::UnknownFailClosed => Some(SelfEvolutionSelectionDefaults {
1189            failure_reason: "self-evolution candidate failed with an unmapped selection reason",
1190            recovery_hint: "Unknown selection failure; fail closed and require explicit maintainer triage before retry.",
1191        }),
1192    }
1193}
1194
1195pub fn accept_self_evolution_selection_decision(
1196    issue_number: u64,
1197    candidate_class: BoundedTaskClass,
1198    summary: Option<&str>,
1199) -> SelfEvolutionSelectionDecision {
1200    let summary = normalize_optional_text(summary).unwrap_or_else(|| {
1201        format!("selected GitHub issue #{issue_number} as a bounded self-evolution candidate")
1202    });
1203    SelfEvolutionSelectionDecision {
1204        issue_number,
1205        selected: true,
1206        candidate_class: Some(candidate_class),
1207        summary,
1208        reason_code: Some(SelfEvolutionSelectionReasonCode::Accepted),
1209        failure_reason: None,
1210        recovery_hint: None,
1211        fail_closed: false,
1212    }
1213}
1214
1215pub fn reject_self_evolution_selection_decision(
1216    issue_number: u64,
1217    reason_code: SelfEvolutionSelectionReasonCode,
1218    failure_reason: Option<&str>,
1219    summary: Option<&str>,
1220) -> SelfEvolutionSelectionDecision {
1221    let defaults = self_evolution_selection_defaults(&reason_code)
1222        .unwrap_or(SelfEvolutionSelectionDefaults {
1223        failure_reason: "self-evolution candidate rejected",
1224        recovery_hint:
1225            "Review candidate selection inputs and retry within the bounded self-evolution policy.",
1226    });
1227    let failure_reason = normalize_optional_text(failure_reason)
1228        .unwrap_or_else(|| defaults.failure_reason.to_string());
1229    let reason_code_key = match reason_code {
1230        SelfEvolutionSelectionReasonCode::Accepted => "accepted",
1231        SelfEvolutionSelectionReasonCode::IssueClosed => "issue_closed",
1232        SelfEvolutionSelectionReasonCode::MissingEvolutionLabel => "missing_evolution_label",
1233        SelfEvolutionSelectionReasonCode::MissingFeatureLabel => "missing_feature_label",
1234        SelfEvolutionSelectionReasonCode::ExcludedByLabel => "excluded_by_label",
1235        SelfEvolutionSelectionReasonCode::UnsupportedCandidateScope => {
1236            "unsupported_candidate_scope"
1237        }
1238        SelfEvolutionSelectionReasonCode::UnknownFailClosed => "unknown_fail_closed",
1239    };
1240    let summary = normalize_optional_text(summary).unwrap_or_else(|| {
1241        format!(
1242            "rejected GitHub issue #{issue_number} as a self-evolution candidate [{reason_code_key}]"
1243        )
1244    });
1245
1246    SelfEvolutionSelectionDecision {
1247        issue_number,
1248        selected: false,
1249        candidate_class: None,
1250        summary,
1251        reason_code: Some(reason_code),
1252        failure_reason: Some(failure_reason),
1253        recovery_hint: Some(defaults.recovery_hint.to_string()),
1254        fail_closed: true,
1255    }
1256}
1257
1258pub fn accept_discovered_candidate(
1259    dedupe_key: impl Into<String>,
1260    candidate_source: AutonomousCandidateSource,
1261    candidate_class: BoundedTaskClass,
1262    signals: Vec<String>,
1263    summary: Option<&str>,
1264) -> DiscoveredCandidate {
1265    let summary = normalize_optional_text(summary)
1266        .unwrap_or_else(|| format!("accepted autonomous candidate from {candidate_source:?}"));
1267    DiscoveredCandidate {
1268        dedupe_key: dedupe_key.into(),
1269        candidate_source,
1270        candidate_class: Some(candidate_class),
1271        signals,
1272        accepted: true,
1273        reason_code: AutonomousIntakeReasonCode::Accepted,
1274        summary,
1275        failure_reason: None,
1276        recovery_hint: None,
1277        fail_closed: false,
1278    }
1279}
1280
1281pub fn deny_discovered_candidate(
1282    dedupe_key: impl Into<String>,
1283    candidate_source: AutonomousCandidateSource,
1284    signals: Vec<String>,
1285    reason_code: AutonomousIntakeReasonCode,
1286) -> DiscoveredCandidate {
1287    let (failure_reason, recovery_hint) = match reason_code {
1288        AutonomousIntakeReasonCode::UnsupportedSignalClass => (
1289            "signal class is not supported by the bounded evolution policy",
1290            "review supported candidate signal classes and filter input before retry",
1291        ),
1292        AutonomousIntakeReasonCode::AmbiguousSignal => (
1293            "signals do not map to a unique bounded candidate class",
1294            "provide more specific signal tokens or triage manually before resubmitting",
1295        ),
1296        AutonomousIntakeReasonCode::DuplicateCandidate => (
1297            "an equivalent candidate has already been discovered in this intake window",
1298            "deduplicate signals before resubmitting or check the existing candidate queue",
1299        ),
1300        AutonomousIntakeReasonCode::UnknownFailClosed => (
1301            "candidate intake failed with an unmapped reason; fail closed",
1302            "require explicit maintainer triage before retry",
1303        ),
1304        AutonomousIntakeReasonCode::Accepted => (
1305            "unexpected accepted reason on deny path",
1306            "use accept_discovered_candidate for accepted outcomes",
1307        ),
1308    };
1309    let summary =
1310        format!("denied autonomous candidate from {candidate_source:?}: {failure_reason}");
1311    DiscoveredCandidate {
1312        dedupe_key: dedupe_key.into(),
1313        candidate_source,
1314        candidate_class: None,
1315        signals,
1316        accepted: false,
1317        reason_code,
1318        summary,
1319        failure_reason: Some(failure_reason.to_string()),
1320        recovery_hint: Some(recovery_hint.to_string()),
1321        fail_closed: true,
1322    }
1323}
1324
1325#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1326pub struct HumanApproval {
1327    pub approved: bool,
1328    pub approver: Option<String>,
1329    pub note: Option<String>,
1330}
1331
1332#[derive(Clone, Debug, Serialize, Deserialize)]
1333pub struct SupervisedDevloopRequest {
1334    pub task: AgentTask,
1335    pub proposal: MutationProposal,
1336    pub approval: HumanApproval,
1337}
1338
1339#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1340pub enum SupervisedDevloopStatus {
1341    AwaitingApproval,
1342    RejectedByPolicy,
1343    FailedClosed,
1344    Executed,
1345}
1346
1347#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1348#[serde(rename_all = "snake_case")]
1349pub enum SupervisedDeliveryStatus {
1350    Prepared,
1351    Denied,
1352}
1353
1354#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1355#[serde(rename_all = "snake_case")]
1356pub enum SupervisedDeliveryApprovalState {
1357    Approved,
1358    MissingExplicitApproval,
1359}
1360
1361#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1362#[serde(rename_all = "snake_case")]
1363pub enum SupervisedDeliveryReasonCode {
1364    DeliveryPrepared,
1365    AwaitingApproval,
1366    DeliveryEvidenceMissing,
1367    ValidationEvidenceMissing,
1368    UnsupportedTaskScope,
1369    InconsistentDeliveryEvidence,
1370    UnknownFailClosed,
1371}
1372
1373#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1374pub struct SupervisedDeliveryContract {
1375    pub delivery_summary: String,
1376    #[serde(default, skip_serializing_if = "Option::is_none")]
1377    pub branch_name: Option<String>,
1378    #[serde(default, skip_serializing_if = "Option::is_none")]
1379    pub pr_title: Option<String>,
1380    #[serde(default, skip_serializing_if = "Option::is_none")]
1381    pub pr_summary: Option<String>,
1382    pub delivery_status: SupervisedDeliveryStatus,
1383    pub approval_state: SupervisedDeliveryApprovalState,
1384    pub reason_code: SupervisedDeliveryReasonCode,
1385    #[serde(default)]
1386    pub fail_closed: bool,
1387    #[serde(default, skip_serializing_if = "Option::is_none")]
1388    pub recovery_hint: Option<String>,
1389}
1390
1391#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1392#[serde(rename_all = "snake_case")]
1393pub enum SupervisedExecutionDecision {
1394    AwaitingApproval,
1395    ReplayHit,
1396    PlannerFallback,
1397    RejectedByPolicy,
1398    FailedClosed,
1399}
1400
1401#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1402#[serde(rename_all = "snake_case")]
1403pub enum SupervisedValidationOutcome {
1404    NotRun,
1405    Passed,
1406    FailedClosed,
1407}
1408
1409#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1410#[serde(rename_all = "snake_case")]
1411pub enum SupervisedExecutionReasonCode {
1412    AwaitingHumanApproval,
1413    ReplayHit,
1414    ReplayFallback,
1415    PolicyDenied,
1416    ValidationFailed,
1417    UnsafePatch,
1418    Timeout,
1419    MutationPayloadMissing,
1420    UnknownFailClosed,
1421}
1422#[derive(Clone, Debug, Serialize, Deserialize)]
1423pub struct SupervisedDevloopOutcome {
1424    pub task_id: String,
1425    pub task_class: Option<BoundedTaskClass>,
1426    pub status: SupervisedDevloopStatus,
1427    pub execution_decision: SupervisedExecutionDecision,
1428    #[serde(default, skip_serializing_if = "Option::is_none")]
1429    pub replay_outcome: Option<ReplayFeedback>,
1430    #[serde(default, skip_serializing_if = "Option::is_none")]
1431    pub fallback_reason: Option<String>,
1432    pub validation_outcome: SupervisedValidationOutcome,
1433    pub evidence_summary: String,
1434    #[serde(default, skip_serializing_if = "Option::is_none")]
1435    pub reason_code: Option<SupervisedExecutionReasonCode>,
1436    #[serde(default, skip_serializing_if = "Option::is_none")]
1437    pub recovery_hint: Option<String>,
1438    pub execution_feedback: Option<ExecutionFeedback>,
1439    #[serde(default, skip_serializing_if = "Option::is_none")]
1440    pub failure_contract: Option<MutationNeededFailureContract>,
1441    pub summary: String,
1442}
1443
1444#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1445#[serde(rename_all = "snake_case")]
1446pub enum SelfEvolutionAuditConsistencyResult {
1447    Consistent,
1448    Inconsistent,
1449}
1450
1451#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1452#[serde(rename_all = "snake_case")]
1453pub enum SelfEvolutionAcceptanceGateReasonCode {
1454    Accepted,
1455    MissingSelectionEvidence,
1456    MissingProposalEvidence,
1457    MissingApprovalEvidence,
1458    MissingExecutionEvidence,
1459    MissingDeliveryEvidence,
1460    InconsistentReasonCodeMatrix,
1461    UnknownFailClosed,
1462}
1463
1464#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1465pub struct SelfEvolutionApprovalEvidence {
1466    pub approval_required: bool,
1467    pub approved: bool,
1468    #[serde(default, skip_serializing_if = "Option::is_none")]
1469    pub approver: Option<String>,
1470}
1471
1472#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1473pub struct SelfEvolutionDeliveryOutcome {
1474    pub delivery_status: SupervisedDeliveryStatus,
1475    pub approval_state: SupervisedDeliveryApprovalState,
1476    pub reason_code: SupervisedDeliveryReasonCode,
1477}
1478
1479#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1480pub struct SelfEvolutionReasonCodeMatrix {
1481    #[serde(default, skip_serializing_if = "Option::is_none")]
1482    pub selection_reason_code: Option<SelfEvolutionSelectionReasonCode>,
1483    pub proposal_reason_code: MutationProposalContractReasonCode,
1484    #[serde(default, skip_serializing_if = "Option::is_none")]
1485    pub execution_reason_code: Option<SupervisedExecutionReasonCode>,
1486    pub delivery_reason_code: SupervisedDeliveryReasonCode,
1487}
1488
1489#[derive(Clone, Debug, Serialize, Deserialize)]
1490pub struct SelfEvolutionAcceptanceGateInput {
1491    pub selection_decision: SelfEvolutionSelectionDecision,
1492    pub proposal_contract: SelfEvolutionMutationProposalContract,
1493    pub supervised_request: SupervisedDevloopRequest,
1494    pub execution_outcome: SupervisedDevloopOutcome,
1495    pub delivery_contract: SupervisedDeliveryContract,
1496}
1497
1498#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1499pub struct SelfEvolutionAcceptanceGateContract {
1500    pub acceptance_gate_summary: String,
1501    pub audit_consistency_result: SelfEvolutionAuditConsistencyResult,
1502    pub approval_evidence: SelfEvolutionApprovalEvidence,
1503    pub delivery_outcome: SelfEvolutionDeliveryOutcome,
1504    pub reason_code_matrix: SelfEvolutionReasonCodeMatrix,
1505    pub fail_closed: bool,
1506    pub reason_code: SelfEvolutionAcceptanceGateReasonCode,
1507    #[serde(default, skip_serializing_if = "Option::is_none")]
1508    pub recovery_hint: Option<String>,
1509}
1510
1511#[cfg(test)]
1512mod tests {
1513    use super::*;
1514
1515    fn handshake_request_with_versions(versions: &[&str]) -> A2aHandshakeRequest {
1516        A2aHandshakeRequest {
1517            agent_id: "agent-test".into(),
1518            role: AgentRole::Planner,
1519            capability_level: AgentCapabilityLevel::A2,
1520            supported_protocols: versions
1521                .iter()
1522                .map(|version| A2aProtocol {
1523                    name: A2A_PROTOCOL_NAME.into(),
1524                    version: (*version).into(),
1525                })
1526                .collect(),
1527            advertised_capabilities: vec![A2aCapability::Coordination],
1528        }
1529    }
1530
1531    #[test]
1532    fn negotiate_supported_protocol_prefers_v1_when_available() {
1533        let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION, A2A_PROTOCOL_VERSION_V1]);
1534        let negotiated = req
1535            .negotiate_supported_protocol()
1536            .expect("expected protocol negotiation success");
1537        assert_eq!(negotiated.name, A2A_PROTOCOL_NAME);
1538        assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION_V1);
1539    }
1540
1541    #[test]
1542    fn negotiate_supported_protocol_falls_back_to_experimental() {
1543        let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION]);
1544        let negotiated = req
1545            .negotiate_supported_protocol()
1546            .expect("expected protocol negotiation success");
1547        assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION);
1548    }
1549
1550    #[test]
1551    fn negotiate_supported_protocol_returns_none_without_overlap() {
1552        let req = handshake_request_with_versions(&["0.0.1"]);
1553        assert!(req.negotiate_supported_protocol().is_none());
1554    }
1555
1556    #[test]
1557    fn normalize_replay_fallback_contract_maps_known_reason() {
1558        let contract = normalize_replay_fallback_contract(
1559            &ReplayPlannerDirective::PlanFallback,
1560            Some("no matching gene"),
1561            None,
1562            None,
1563            None,
1564            None,
1565        )
1566        .expect("contract should exist");
1567
1568        assert_eq!(
1569            contract.reason_code,
1570            ReplayFallbackReasonCode::NoCandidateAfterSelect
1571        );
1572        assert_eq!(
1573            contract.next_action,
1574            ReplayFallbackNextAction::PlanFromScratch
1575        );
1576        assert_eq!(contract.confidence, 92);
1577    }
1578
1579    #[test]
1580    fn normalize_replay_fallback_contract_fails_closed_for_unknown_reason() {
1581        let contract = normalize_replay_fallback_contract(
1582            &ReplayPlannerDirective::PlanFallback,
1583            Some("something unexpected"),
1584            None,
1585            None,
1586            None,
1587            None,
1588        )
1589        .expect("contract should exist");
1590
1591        assert_eq!(
1592            contract.reason_code,
1593            ReplayFallbackReasonCode::UnmappedFallbackReason
1594        );
1595        assert_eq!(
1596            contract.next_action,
1597            ReplayFallbackNextAction::EscalateFailClosed
1598        );
1599        assert_eq!(contract.confidence, 0);
1600    }
1601
1602    #[test]
1603    fn normalize_replay_fallback_contract_rejects_conflicting_next_action() {
1604        let contract = normalize_replay_fallback_contract(
1605            &ReplayPlannerDirective::PlanFallback,
1606            Some("replay validation failed"),
1607            Some(ReplayFallbackReasonCode::ValidationFailed),
1608            None,
1609            Some(ReplayFallbackNextAction::PlanFromScratch),
1610            Some(88),
1611        )
1612        .expect("contract should exist");
1613
1614        assert_eq!(
1615            contract.reason_code,
1616            ReplayFallbackReasonCode::UnmappedFallbackReason
1617        );
1618        assert_eq!(
1619            contract.next_action,
1620            ReplayFallbackNextAction::EscalateFailClosed
1621        );
1622        assert_eq!(contract.confidence, 0);
1623    }
1624
1625    #[test]
1626    fn normalize_mutation_needed_failure_contract_maps_policy_denied() {
1627        let contract = normalize_mutation_needed_failure_contract(
1628            Some("supervised devloop rejected task because it is outside bounded scope"),
1629            None,
1630        );
1631
1632        assert_eq!(
1633            contract.reason_code,
1634            MutationNeededFailureReasonCode::PolicyDenied
1635        );
1636        assert_eq!(
1637            contract.recovery_action,
1638            MutationNeededRecoveryAction::NarrowScopeAndRetry
1639        );
1640        assert!(contract.fail_closed);
1641    }
1642
1643    #[test]
1644    fn normalize_mutation_needed_failure_contract_maps_timeout() {
1645        let contract = normalize_mutation_needed_failure_contract(
1646            Some("command timed out: git apply --check patch.diff"),
1647            None,
1648        );
1649
1650        assert_eq!(
1651            contract.reason_code,
1652            MutationNeededFailureReasonCode::Timeout
1653        );
1654        assert_eq!(
1655            contract.recovery_action,
1656            MutationNeededRecoveryAction::ReduceExecutionBudget
1657        );
1658        assert!(contract.fail_closed);
1659    }
1660
1661    #[test]
1662    fn normalize_mutation_needed_failure_contract_fails_closed_for_unknown_reason() {
1663        let contract =
1664            normalize_mutation_needed_failure_contract(Some("unexpected runner panic"), None);
1665
1666        assert_eq!(
1667            contract.reason_code,
1668            MutationNeededFailureReasonCode::UnknownFailClosed
1669        );
1670        assert_eq!(
1671            contract.recovery_action,
1672            MutationNeededRecoveryAction::EscalateFailClosed
1673        );
1674        assert!(contract.fail_closed);
1675    }
1676
1677    #[test]
1678    fn reject_self_evolution_selection_decision_maps_closed_issue_defaults() {
1679        let decision = reject_self_evolution_selection_decision(
1680            234,
1681            SelfEvolutionSelectionReasonCode::IssueClosed,
1682            None,
1683            None,
1684        );
1685
1686        assert!(!decision.selected);
1687        assert_eq!(decision.issue_number, 234);
1688        assert_eq!(
1689            decision.reason_code,
1690            Some(SelfEvolutionSelectionReasonCode::IssueClosed)
1691        );
1692        assert!(decision.fail_closed);
1693        assert!(decision
1694            .failure_reason
1695            .as_deref()
1696            .is_some_and(|reason| reason.contains("closed")));
1697        assert!(decision.recovery_hint.is_some());
1698    }
1699
1700    #[test]
1701    fn accept_self_evolution_selection_decision_marks_candidate_selected() {
1702        let decision =
1703            accept_self_evolution_selection_decision(235, BoundedTaskClass::DocsSingleFile, None);
1704
1705        assert!(decision.selected);
1706        assert_eq!(decision.issue_number, 235);
1707        assert_eq!(
1708            decision.candidate_class,
1709            Some(BoundedTaskClass::DocsSingleFile)
1710        );
1711        assert_eq!(
1712            decision.reason_code,
1713            Some(SelfEvolutionSelectionReasonCode::Accepted)
1714        );
1715        assert!(!decision.fail_closed);
1716        assert_eq!(decision.failure_reason, None);
1717        assert_eq!(decision.recovery_hint, None);
1718    }
1719}
1720
1721/// Hub trust tier - defines operational permissions for a Hub
1722#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1723pub enum HubTrustTier {
1724    /// Full trust - allows all operations (internal/private Hub)
1725    Full,
1726    /// Read-only - allows only read operations (public Hub)
1727    ReadOnly,
1728}
1729
1730/// Hub operation class - classifies the type of A2A operation
1731#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
1732pub enum HubOperationClass {
1733    Hello,
1734    Fetch,
1735    Publish,
1736    Revoke,
1737    TaskClaim,
1738    TaskComplete,
1739    WorkerRegister,
1740    Recipe,
1741    Session,
1742    Dispute,
1743    Swarm,
1744}
1745
1746impl HubOperationClass {
1747    /// Returns true if the operation is read-only (allowed for ReadOnly hubs)
1748    pub fn is_read_only(&self) -> bool {
1749        matches!(self, HubOperationClass::Hello | HubOperationClass::Fetch)
1750    }
1751}
1752
1753/// Hub profile - describes a Hub's capabilities and configuration
1754#[derive(Clone, Debug, Serialize, Deserialize)]
1755pub struct HubProfile {
1756    pub hub_id: String,
1757    pub base_url: String,
1758    pub trust_tier: HubTrustTier,
1759    /// Priority for hub selection (higher = preferred)
1760    pub priority: u32,
1761    /// Optional health check endpoint
1762    pub health_url: Option<String>,
1763}
1764
1765impl HubProfile {
1766    /// Check if this hub allows the given operation class
1767    pub fn allows_operation(&self, operation: &HubOperationClass) -> bool {
1768        match &self.trust_tier {
1769            HubTrustTier::Full => true,
1770            HubTrustTier::ReadOnly => operation.is_read_only(),
1771        }
1772    }
1773}
1774
1775/// Hub selection policy - defines how to choose between multiple hubs
1776#[derive(Clone, Debug)]
1777pub struct HubSelectionPolicy {
1778    /// Map operation class to allowed trust tiers
1779    pub allowed_tiers_for_operation: Vec<(HubOperationClass, Vec<HubTrustTier>)>,
1780    /// Default trust tiers if no specific mapping
1781    pub default_allowed_tiers: Vec<HubTrustTier>,
1782}
1783
1784impl Default for HubSelectionPolicy {
1785    fn default() -> Self {
1786        Self {
1787            allowed_tiers_for_operation: vec![
1788                (
1789                    HubOperationClass::Hello,
1790                    vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
1791                ),
1792                (
1793                    HubOperationClass::Fetch,
1794                    vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
1795                ),
1796                // All write operations require Full trust
1797                (HubOperationClass::Publish, vec![HubTrustTier::Full]),
1798                (HubOperationClass::Revoke, vec![HubTrustTier::Full]),
1799                (HubOperationClass::TaskClaim, vec![HubTrustTier::Full]),
1800                (HubOperationClass::TaskComplete, vec![HubTrustTier::Full]),
1801                (HubOperationClass::WorkerRegister, vec![HubTrustTier::Full]),
1802                (HubOperationClass::Recipe, vec![HubTrustTier::Full]),
1803                (HubOperationClass::Session, vec![HubTrustTier::Full]),
1804                (HubOperationClass::Dispute, vec![HubTrustTier::Full]),
1805                (HubOperationClass::Swarm, vec![HubTrustTier::Full]),
1806            ],
1807            default_allowed_tiers: vec![HubTrustTier::Full],
1808        }
1809    }
1810}
1811
1812impl HubSelectionPolicy {
1813    /// Get allowed trust tiers for a given operation
1814    pub fn allowed_tiers(&self, operation: &HubOperationClass) -> &[HubTrustTier] {
1815        self.allowed_tiers_for_operation
1816            .iter()
1817            .find(|(op, _)| op == operation)
1818            .map(|(_, tiers)| tiers.as_slice())
1819            .unwrap_or(&self.default_allowed_tiers)
1820    }
1821}