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#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
962#[serde(rename_all = "snake_case")]
963pub enum SelfEvolutionSelectionReasonCode {
964    Accepted,
965    IssueClosed,
966    MissingEvolutionLabel,
967    MissingFeatureLabel,
968    ExcludedByLabel,
969    UnsupportedCandidateScope,
970    UnknownFailClosed,
971}
972
973#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
974pub struct SelfEvolutionSelectionDecision {
975    pub issue_number: u64,
976    pub selected: bool,
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub candidate_class: Option<BoundedTaskClass>,
979    pub summary: String,
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub reason_code: Option<SelfEvolutionSelectionReasonCode>,
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub failure_reason: Option<String>,
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub recovery_hint: Option<String>,
986    pub fail_closed: bool,
987}
988
989#[derive(Clone, Copy)]
990struct SelfEvolutionSelectionDefaults {
991    failure_reason: &'static str,
992    recovery_hint: &'static str,
993}
994
995fn self_evolution_selection_defaults(
996    reason_code: &SelfEvolutionSelectionReasonCode,
997) -> Option<SelfEvolutionSelectionDefaults> {
998    match reason_code {
999        SelfEvolutionSelectionReasonCode::Accepted => None,
1000        SelfEvolutionSelectionReasonCode::IssueClosed => Some(SelfEvolutionSelectionDefaults {
1001            failure_reason: "self-evolution candidate rejected because the issue is closed",
1002            recovery_hint: "Reopen the issue or choose an active open issue before retrying selection.",
1003        }),
1004        SelfEvolutionSelectionReasonCode::MissingEvolutionLabel => {
1005            Some(SelfEvolutionSelectionDefaults {
1006                failure_reason: "self-evolution candidate rejected because the issue is missing area/evolution",
1007                recovery_hint:
1008                    "Add the area/evolution label or choose an issue already scoped to self-evolution.",
1009            })
1010        }
1011        SelfEvolutionSelectionReasonCode::MissingFeatureLabel => {
1012            Some(SelfEvolutionSelectionDefaults {
1013                failure_reason: "self-evolution candidate rejected because the issue is missing type/feature",
1014                recovery_hint:
1015                    "Add the type/feature label or narrow the issue to a bounded feature slice before retrying.",
1016            })
1017        }
1018        SelfEvolutionSelectionReasonCode::ExcludedByLabel => Some(SelfEvolutionSelectionDefaults {
1019            failure_reason: "self-evolution candidate rejected by an excluded issue label",
1020            recovery_hint:
1021                "Remove the excluded label or choose a non-duplicate, non-invalid, actionable issue.",
1022        }),
1023        SelfEvolutionSelectionReasonCode::UnsupportedCandidateScope => {
1024            Some(SelfEvolutionSelectionDefaults {
1025                failure_reason:
1026                    "self-evolution candidate rejected because the hinted file scope is outside the bounded docs policy",
1027                recovery_hint:
1028                    "Narrow candidate paths to the approved docs/*.md boundary before retrying selection.",
1029            })
1030        }
1031        SelfEvolutionSelectionReasonCode::UnknownFailClosed => Some(SelfEvolutionSelectionDefaults {
1032            failure_reason: "self-evolution candidate failed with an unmapped selection reason",
1033            recovery_hint: "Unknown selection failure; fail closed and require explicit maintainer triage before retry.",
1034        }),
1035    }
1036}
1037
1038pub fn accept_self_evolution_selection_decision(
1039    issue_number: u64,
1040    candidate_class: BoundedTaskClass,
1041    summary: Option<&str>,
1042) -> SelfEvolutionSelectionDecision {
1043    let summary = normalize_optional_text(summary).unwrap_or_else(|| {
1044        format!("selected GitHub issue #{issue_number} as a bounded self-evolution candidate")
1045    });
1046    SelfEvolutionSelectionDecision {
1047        issue_number,
1048        selected: true,
1049        candidate_class: Some(candidate_class),
1050        summary,
1051        reason_code: Some(SelfEvolutionSelectionReasonCode::Accepted),
1052        failure_reason: None,
1053        recovery_hint: None,
1054        fail_closed: false,
1055    }
1056}
1057
1058pub fn reject_self_evolution_selection_decision(
1059    issue_number: u64,
1060    reason_code: SelfEvolutionSelectionReasonCode,
1061    failure_reason: Option<&str>,
1062    summary: Option<&str>,
1063) -> SelfEvolutionSelectionDecision {
1064    let defaults = self_evolution_selection_defaults(&reason_code)
1065        .unwrap_or(SelfEvolutionSelectionDefaults {
1066        failure_reason: "self-evolution candidate rejected",
1067        recovery_hint:
1068            "Review candidate selection inputs and retry within the bounded self-evolution policy.",
1069    });
1070    let failure_reason = normalize_optional_text(failure_reason)
1071        .unwrap_or_else(|| defaults.failure_reason.to_string());
1072    let reason_code_key = match reason_code {
1073        SelfEvolutionSelectionReasonCode::Accepted => "accepted",
1074        SelfEvolutionSelectionReasonCode::IssueClosed => "issue_closed",
1075        SelfEvolutionSelectionReasonCode::MissingEvolutionLabel => "missing_evolution_label",
1076        SelfEvolutionSelectionReasonCode::MissingFeatureLabel => "missing_feature_label",
1077        SelfEvolutionSelectionReasonCode::ExcludedByLabel => "excluded_by_label",
1078        SelfEvolutionSelectionReasonCode::UnsupportedCandidateScope => {
1079            "unsupported_candidate_scope"
1080        }
1081        SelfEvolutionSelectionReasonCode::UnknownFailClosed => "unknown_fail_closed",
1082    };
1083    let summary = normalize_optional_text(summary).unwrap_or_else(|| {
1084        format!(
1085            "rejected GitHub issue #{issue_number} as a self-evolution candidate [{reason_code_key}]"
1086        )
1087    });
1088
1089    SelfEvolutionSelectionDecision {
1090        issue_number,
1091        selected: false,
1092        candidate_class: None,
1093        summary,
1094        reason_code: Some(reason_code),
1095        failure_reason: Some(failure_reason),
1096        recovery_hint: Some(defaults.recovery_hint.to_string()),
1097        fail_closed: true,
1098    }
1099}
1100
1101pub fn accept_discovered_candidate(
1102    dedupe_key: impl Into<String>,
1103    candidate_source: AutonomousCandidateSource,
1104    candidate_class: BoundedTaskClass,
1105    signals: Vec<String>,
1106    summary: Option<&str>,
1107) -> DiscoveredCandidate {
1108    let summary = normalize_optional_text(summary)
1109        .unwrap_or_else(|| format!("accepted autonomous candidate from {candidate_source:?}"));
1110    DiscoveredCandidate {
1111        dedupe_key: dedupe_key.into(),
1112        candidate_source,
1113        candidate_class: Some(candidate_class),
1114        signals,
1115        accepted: true,
1116        reason_code: AutonomousIntakeReasonCode::Accepted,
1117        summary,
1118        failure_reason: None,
1119        recovery_hint: None,
1120        fail_closed: false,
1121    }
1122}
1123
1124pub fn deny_discovered_candidate(
1125    dedupe_key: impl Into<String>,
1126    candidate_source: AutonomousCandidateSource,
1127    signals: Vec<String>,
1128    reason_code: AutonomousIntakeReasonCode,
1129) -> DiscoveredCandidate {
1130    let (failure_reason, recovery_hint) = match reason_code {
1131        AutonomousIntakeReasonCode::UnsupportedSignalClass => (
1132            "signal class is not supported by the bounded evolution policy",
1133            "review supported candidate signal classes and filter input before retry",
1134        ),
1135        AutonomousIntakeReasonCode::AmbiguousSignal => (
1136            "signals do not map to a unique bounded candidate class",
1137            "provide more specific signal tokens or triage manually before resubmitting",
1138        ),
1139        AutonomousIntakeReasonCode::DuplicateCandidate => (
1140            "an equivalent candidate has already been discovered in this intake window",
1141            "deduplicate signals before resubmitting or check the existing candidate queue",
1142        ),
1143        AutonomousIntakeReasonCode::UnknownFailClosed => (
1144            "candidate intake failed with an unmapped reason; fail closed",
1145            "require explicit maintainer triage before retry",
1146        ),
1147        AutonomousIntakeReasonCode::Accepted => (
1148            "unexpected accepted reason on deny path",
1149            "use accept_discovered_candidate for accepted outcomes",
1150        ),
1151    };
1152    let summary =
1153        format!("denied autonomous candidate from {candidate_source:?}: {failure_reason}");
1154    DiscoveredCandidate {
1155        dedupe_key: dedupe_key.into(),
1156        candidate_source,
1157        candidate_class: None,
1158        signals,
1159        accepted: false,
1160        reason_code,
1161        summary,
1162        failure_reason: Some(failure_reason.to_string()),
1163        recovery_hint: Some(recovery_hint.to_string()),
1164        fail_closed: true,
1165    }
1166}
1167
1168#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1169pub struct HumanApproval {
1170    pub approved: bool,
1171    pub approver: Option<String>,
1172    pub note: Option<String>,
1173}
1174
1175#[derive(Clone, Debug, Serialize, Deserialize)]
1176pub struct SupervisedDevloopRequest {
1177    pub task: AgentTask,
1178    pub proposal: MutationProposal,
1179    pub approval: HumanApproval,
1180}
1181
1182#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1183pub enum SupervisedDevloopStatus {
1184    AwaitingApproval,
1185    RejectedByPolicy,
1186    FailedClosed,
1187    Executed,
1188}
1189
1190#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1191#[serde(rename_all = "snake_case")]
1192pub enum SupervisedDeliveryStatus {
1193    Prepared,
1194    Denied,
1195}
1196
1197#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1198#[serde(rename_all = "snake_case")]
1199pub enum SupervisedDeliveryApprovalState {
1200    Approved,
1201    MissingExplicitApproval,
1202}
1203
1204#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1205#[serde(rename_all = "snake_case")]
1206pub enum SupervisedDeliveryReasonCode {
1207    DeliveryPrepared,
1208    AwaitingApproval,
1209    DeliveryEvidenceMissing,
1210    ValidationEvidenceMissing,
1211    UnsupportedTaskScope,
1212    InconsistentDeliveryEvidence,
1213    UnknownFailClosed,
1214}
1215
1216#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1217pub struct SupervisedDeliveryContract {
1218    pub delivery_summary: String,
1219    #[serde(default, skip_serializing_if = "Option::is_none")]
1220    pub branch_name: Option<String>,
1221    #[serde(default, skip_serializing_if = "Option::is_none")]
1222    pub pr_title: Option<String>,
1223    #[serde(default, skip_serializing_if = "Option::is_none")]
1224    pub pr_summary: Option<String>,
1225    pub delivery_status: SupervisedDeliveryStatus,
1226    pub approval_state: SupervisedDeliveryApprovalState,
1227    pub reason_code: SupervisedDeliveryReasonCode,
1228    #[serde(default)]
1229    pub fail_closed: bool,
1230    #[serde(default, skip_serializing_if = "Option::is_none")]
1231    pub recovery_hint: Option<String>,
1232}
1233
1234#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1235#[serde(rename_all = "snake_case")]
1236pub enum SupervisedExecutionDecision {
1237    AwaitingApproval,
1238    ReplayHit,
1239    PlannerFallback,
1240    RejectedByPolicy,
1241    FailedClosed,
1242}
1243
1244#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1245#[serde(rename_all = "snake_case")]
1246pub enum SupervisedValidationOutcome {
1247    NotRun,
1248    Passed,
1249    FailedClosed,
1250}
1251
1252#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1253#[serde(rename_all = "snake_case")]
1254pub enum SupervisedExecutionReasonCode {
1255    AwaitingHumanApproval,
1256    ReplayHit,
1257    ReplayFallback,
1258    PolicyDenied,
1259    ValidationFailed,
1260    UnsafePatch,
1261    Timeout,
1262    MutationPayloadMissing,
1263    UnknownFailClosed,
1264}
1265#[derive(Clone, Debug, Serialize, Deserialize)]
1266pub struct SupervisedDevloopOutcome {
1267    pub task_id: String,
1268    pub task_class: Option<BoundedTaskClass>,
1269    pub status: SupervisedDevloopStatus,
1270    pub execution_decision: SupervisedExecutionDecision,
1271    #[serde(default, skip_serializing_if = "Option::is_none")]
1272    pub replay_outcome: Option<ReplayFeedback>,
1273    #[serde(default, skip_serializing_if = "Option::is_none")]
1274    pub fallback_reason: Option<String>,
1275    pub validation_outcome: SupervisedValidationOutcome,
1276    pub evidence_summary: String,
1277    #[serde(default, skip_serializing_if = "Option::is_none")]
1278    pub reason_code: Option<SupervisedExecutionReasonCode>,
1279    #[serde(default, skip_serializing_if = "Option::is_none")]
1280    pub recovery_hint: Option<String>,
1281    pub execution_feedback: Option<ExecutionFeedback>,
1282    #[serde(default, skip_serializing_if = "Option::is_none")]
1283    pub failure_contract: Option<MutationNeededFailureContract>,
1284    pub summary: String,
1285}
1286
1287#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1288#[serde(rename_all = "snake_case")]
1289pub enum SelfEvolutionAuditConsistencyResult {
1290    Consistent,
1291    Inconsistent,
1292}
1293
1294#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1295#[serde(rename_all = "snake_case")]
1296pub enum SelfEvolutionAcceptanceGateReasonCode {
1297    Accepted,
1298    MissingSelectionEvidence,
1299    MissingProposalEvidence,
1300    MissingApprovalEvidence,
1301    MissingExecutionEvidence,
1302    MissingDeliveryEvidence,
1303    InconsistentReasonCodeMatrix,
1304    UnknownFailClosed,
1305}
1306
1307#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1308pub struct SelfEvolutionApprovalEvidence {
1309    pub approval_required: bool,
1310    pub approved: bool,
1311    #[serde(default, skip_serializing_if = "Option::is_none")]
1312    pub approver: Option<String>,
1313}
1314
1315#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1316pub struct SelfEvolutionDeliveryOutcome {
1317    pub delivery_status: SupervisedDeliveryStatus,
1318    pub approval_state: SupervisedDeliveryApprovalState,
1319    pub reason_code: SupervisedDeliveryReasonCode,
1320}
1321
1322#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1323pub struct SelfEvolutionReasonCodeMatrix {
1324    #[serde(default, skip_serializing_if = "Option::is_none")]
1325    pub selection_reason_code: Option<SelfEvolutionSelectionReasonCode>,
1326    pub proposal_reason_code: MutationProposalContractReasonCode,
1327    #[serde(default, skip_serializing_if = "Option::is_none")]
1328    pub execution_reason_code: Option<SupervisedExecutionReasonCode>,
1329    pub delivery_reason_code: SupervisedDeliveryReasonCode,
1330}
1331
1332#[derive(Clone, Debug, Serialize, Deserialize)]
1333pub struct SelfEvolutionAcceptanceGateInput {
1334    pub selection_decision: SelfEvolutionSelectionDecision,
1335    pub proposal_contract: SelfEvolutionMutationProposalContract,
1336    pub supervised_request: SupervisedDevloopRequest,
1337    pub execution_outcome: SupervisedDevloopOutcome,
1338    pub delivery_contract: SupervisedDeliveryContract,
1339}
1340
1341#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1342pub struct SelfEvolutionAcceptanceGateContract {
1343    pub acceptance_gate_summary: String,
1344    pub audit_consistency_result: SelfEvolutionAuditConsistencyResult,
1345    pub approval_evidence: SelfEvolutionApprovalEvidence,
1346    pub delivery_outcome: SelfEvolutionDeliveryOutcome,
1347    pub reason_code_matrix: SelfEvolutionReasonCodeMatrix,
1348    pub fail_closed: bool,
1349    pub reason_code: SelfEvolutionAcceptanceGateReasonCode,
1350    #[serde(default, skip_serializing_if = "Option::is_none")]
1351    pub recovery_hint: Option<String>,
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357
1358    fn handshake_request_with_versions(versions: &[&str]) -> A2aHandshakeRequest {
1359        A2aHandshakeRequest {
1360            agent_id: "agent-test".into(),
1361            role: AgentRole::Planner,
1362            capability_level: AgentCapabilityLevel::A2,
1363            supported_protocols: versions
1364                .iter()
1365                .map(|version| A2aProtocol {
1366                    name: A2A_PROTOCOL_NAME.into(),
1367                    version: (*version).into(),
1368                })
1369                .collect(),
1370            advertised_capabilities: vec![A2aCapability::Coordination],
1371        }
1372    }
1373
1374    #[test]
1375    fn negotiate_supported_protocol_prefers_v1_when_available() {
1376        let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION, A2A_PROTOCOL_VERSION_V1]);
1377        let negotiated = req
1378            .negotiate_supported_protocol()
1379            .expect("expected protocol negotiation success");
1380        assert_eq!(negotiated.name, A2A_PROTOCOL_NAME);
1381        assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION_V1);
1382    }
1383
1384    #[test]
1385    fn negotiate_supported_protocol_falls_back_to_experimental() {
1386        let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION]);
1387        let negotiated = req
1388            .negotiate_supported_protocol()
1389            .expect("expected protocol negotiation success");
1390        assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION);
1391    }
1392
1393    #[test]
1394    fn negotiate_supported_protocol_returns_none_without_overlap() {
1395        let req = handshake_request_with_versions(&["0.0.1"]);
1396        assert!(req.negotiate_supported_protocol().is_none());
1397    }
1398
1399    #[test]
1400    fn normalize_replay_fallback_contract_maps_known_reason() {
1401        let contract = normalize_replay_fallback_contract(
1402            &ReplayPlannerDirective::PlanFallback,
1403            Some("no matching gene"),
1404            None,
1405            None,
1406            None,
1407            None,
1408        )
1409        .expect("contract should exist");
1410
1411        assert_eq!(
1412            contract.reason_code,
1413            ReplayFallbackReasonCode::NoCandidateAfterSelect
1414        );
1415        assert_eq!(
1416            contract.next_action,
1417            ReplayFallbackNextAction::PlanFromScratch
1418        );
1419        assert_eq!(contract.confidence, 92);
1420    }
1421
1422    #[test]
1423    fn normalize_replay_fallback_contract_fails_closed_for_unknown_reason() {
1424        let contract = normalize_replay_fallback_contract(
1425            &ReplayPlannerDirective::PlanFallback,
1426            Some("something unexpected"),
1427            None,
1428            None,
1429            None,
1430            None,
1431        )
1432        .expect("contract should exist");
1433
1434        assert_eq!(
1435            contract.reason_code,
1436            ReplayFallbackReasonCode::UnmappedFallbackReason
1437        );
1438        assert_eq!(
1439            contract.next_action,
1440            ReplayFallbackNextAction::EscalateFailClosed
1441        );
1442        assert_eq!(contract.confidence, 0);
1443    }
1444
1445    #[test]
1446    fn normalize_replay_fallback_contract_rejects_conflicting_next_action() {
1447        let contract = normalize_replay_fallback_contract(
1448            &ReplayPlannerDirective::PlanFallback,
1449            Some("replay validation failed"),
1450            Some(ReplayFallbackReasonCode::ValidationFailed),
1451            None,
1452            Some(ReplayFallbackNextAction::PlanFromScratch),
1453            Some(88),
1454        )
1455        .expect("contract should exist");
1456
1457        assert_eq!(
1458            contract.reason_code,
1459            ReplayFallbackReasonCode::UnmappedFallbackReason
1460        );
1461        assert_eq!(
1462            contract.next_action,
1463            ReplayFallbackNextAction::EscalateFailClosed
1464        );
1465        assert_eq!(contract.confidence, 0);
1466    }
1467
1468    #[test]
1469    fn normalize_mutation_needed_failure_contract_maps_policy_denied() {
1470        let contract = normalize_mutation_needed_failure_contract(
1471            Some("supervised devloop rejected task because it is outside bounded scope"),
1472            None,
1473        );
1474
1475        assert_eq!(
1476            contract.reason_code,
1477            MutationNeededFailureReasonCode::PolicyDenied
1478        );
1479        assert_eq!(
1480            contract.recovery_action,
1481            MutationNeededRecoveryAction::NarrowScopeAndRetry
1482        );
1483        assert!(contract.fail_closed);
1484    }
1485
1486    #[test]
1487    fn normalize_mutation_needed_failure_contract_maps_timeout() {
1488        let contract = normalize_mutation_needed_failure_contract(
1489            Some("command timed out: git apply --check patch.diff"),
1490            None,
1491        );
1492
1493        assert_eq!(
1494            contract.reason_code,
1495            MutationNeededFailureReasonCode::Timeout
1496        );
1497        assert_eq!(
1498            contract.recovery_action,
1499            MutationNeededRecoveryAction::ReduceExecutionBudget
1500        );
1501        assert!(contract.fail_closed);
1502    }
1503
1504    #[test]
1505    fn normalize_mutation_needed_failure_contract_fails_closed_for_unknown_reason() {
1506        let contract =
1507            normalize_mutation_needed_failure_contract(Some("unexpected runner panic"), None);
1508
1509        assert_eq!(
1510            contract.reason_code,
1511            MutationNeededFailureReasonCode::UnknownFailClosed
1512        );
1513        assert_eq!(
1514            contract.recovery_action,
1515            MutationNeededRecoveryAction::EscalateFailClosed
1516        );
1517        assert!(contract.fail_closed);
1518    }
1519
1520    #[test]
1521    fn reject_self_evolution_selection_decision_maps_closed_issue_defaults() {
1522        let decision = reject_self_evolution_selection_decision(
1523            234,
1524            SelfEvolutionSelectionReasonCode::IssueClosed,
1525            None,
1526            None,
1527        );
1528
1529        assert!(!decision.selected);
1530        assert_eq!(decision.issue_number, 234);
1531        assert_eq!(
1532            decision.reason_code,
1533            Some(SelfEvolutionSelectionReasonCode::IssueClosed)
1534        );
1535        assert!(decision.fail_closed);
1536        assert!(decision
1537            .failure_reason
1538            .as_deref()
1539            .is_some_and(|reason| reason.contains("closed")));
1540        assert!(decision.recovery_hint.is_some());
1541    }
1542
1543    #[test]
1544    fn accept_self_evolution_selection_decision_marks_candidate_selected() {
1545        let decision =
1546            accept_self_evolution_selection_decision(235, BoundedTaskClass::DocsSingleFile, None);
1547
1548        assert!(decision.selected);
1549        assert_eq!(decision.issue_number, 235);
1550        assert_eq!(
1551            decision.candidate_class,
1552            Some(BoundedTaskClass::DocsSingleFile)
1553        );
1554        assert_eq!(
1555            decision.reason_code,
1556            Some(SelfEvolutionSelectionReasonCode::Accepted)
1557        );
1558        assert!(!decision.fail_closed);
1559        assert_eq!(decision.failure_reason, None);
1560        assert_eq!(decision.recovery_hint, None);
1561    }
1562}
1563
1564/// Hub trust tier - defines operational permissions for a Hub
1565#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1566pub enum HubTrustTier {
1567    /// Full trust - allows all operations (internal/private Hub)
1568    Full,
1569    /// Read-only - allows only read operations (public Hub)
1570    ReadOnly,
1571}
1572
1573/// Hub operation class - classifies the type of A2A operation
1574#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
1575pub enum HubOperationClass {
1576    Hello,
1577    Fetch,
1578    Publish,
1579    Revoke,
1580    TaskClaim,
1581    TaskComplete,
1582    WorkerRegister,
1583    Recipe,
1584    Session,
1585    Dispute,
1586    Swarm,
1587}
1588
1589impl HubOperationClass {
1590    /// Returns true if the operation is read-only (allowed for ReadOnly hubs)
1591    pub fn is_read_only(&self) -> bool {
1592        matches!(self, HubOperationClass::Hello | HubOperationClass::Fetch)
1593    }
1594}
1595
1596/// Hub profile - describes a Hub's capabilities and configuration
1597#[derive(Clone, Debug, Serialize, Deserialize)]
1598pub struct HubProfile {
1599    pub hub_id: String,
1600    pub base_url: String,
1601    pub trust_tier: HubTrustTier,
1602    /// Priority for hub selection (higher = preferred)
1603    pub priority: u32,
1604    /// Optional health check endpoint
1605    pub health_url: Option<String>,
1606}
1607
1608impl HubProfile {
1609    /// Check if this hub allows the given operation class
1610    pub fn allows_operation(&self, operation: &HubOperationClass) -> bool {
1611        match &self.trust_tier {
1612            HubTrustTier::Full => true,
1613            HubTrustTier::ReadOnly => operation.is_read_only(),
1614        }
1615    }
1616}
1617
1618/// Hub selection policy - defines how to choose between multiple hubs
1619#[derive(Clone, Debug)]
1620pub struct HubSelectionPolicy {
1621    /// Map operation class to allowed trust tiers
1622    pub allowed_tiers_for_operation: Vec<(HubOperationClass, Vec<HubTrustTier>)>,
1623    /// Default trust tiers if no specific mapping
1624    pub default_allowed_tiers: Vec<HubTrustTier>,
1625}
1626
1627impl Default for HubSelectionPolicy {
1628    fn default() -> Self {
1629        Self {
1630            allowed_tiers_for_operation: vec![
1631                (
1632                    HubOperationClass::Hello,
1633                    vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
1634                ),
1635                (
1636                    HubOperationClass::Fetch,
1637                    vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
1638                ),
1639                // All write operations require Full trust
1640                (HubOperationClass::Publish, vec![HubTrustTier::Full]),
1641                (HubOperationClass::Revoke, vec![HubTrustTier::Full]),
1642                (HubOperationClass::TaskClaim, vec![HubTrustTier::Full]),
1643                (HubOperationClass::TaskComplete, vec![HubTrustTier::Full]),
1644                (HubOperationClass::WorkerRegister, vec![HubTrustTier::Full]),
1645                (HubOperationClass::Recipe, vec![HubTrustTier::Full]),
1646                (HubOperationClass::Session, vec![HubTrustTier::Full]),
1647                (HubOperationClass::Dispute, vec![HubTrustTier::Full]),
1648                (HubOperationClass::Swarm, vec![HubTrustTier::Full]),
1649            ],
1650            default_allowed_tiers: vec![HubTrustTier::Full],
1651        }
1652    }
1653}
1654
1655impl HubSelectionPolicy {
1656    /// Get allowed trust tiers for a given operation
1657    pub fn allowed_tiers(&self, operation: &HubOperationClass) -> &[HubTrustTier] {
1658        self.allowed_tiers_for_operation
1659            .iter()
1660            .find(|(op, _)| op == operation)
1661            .map(|(_, tiers)| tiers.as_slice())
1662            .unwrap_or(&self.default_allowed_tiers)
1663    }
1664}