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// ──────────────────────────────────────────────────────────────────────────────
1119// AUTO-04: Semantic Task-Class Generalization Beyond Normalized Signals
1120// ──────────────────────────────────────────────────────────────────────────────
1121
1122/// Broader semantic equivalence families for replay generalization.
1123///
1124/// A `TaskEquivalenceClass` groups bounded task classes that share enough
1125/// semantic properties to allow cross-task replay within the family while
1126/// keeping false-positive replay rates at zero for unrelated work.
1127#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1128#[serde(rename_all = "snake_case")]
1129pub enum TaskEquivalenceClass {
1130    /// Any documentation edit — single-file or multi-file docs changes are
1131    /// in the same semantic family.
1132    DocumentationEdit,
1133    /// Pure static-analysis or compiler-driven code fixes (lints, clippy, fmt).
1134    StaticAnalysisFix,
1135    /// Dependency manifest updates (Cargo.toml, Cargo.lock upgrades).
1136    DependencyManifestUpdate,
1137    /// Catch-all: task does not belong to any recognized equivalence family.
1138    Unclassified,
1139}
1140
1141/// Human-readable and machine-auditable explanation for why two tasks are
1142/// considered semantically equivalent for replay selection purposes.
1143#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1144pub struct EquivalenceExplanation {
1145    /// The equivalence class that was matched.
1146    pub task_equivalence_class: TaskEquivalenceClass,
1147    /// Short human-readable rationale for the equivalence match.
1148    pub rationale: String,
1149    /// Specific features that were used to determine equivalence.
1150    pub matching_features: Vec<String>,
1151    /// Confidence score in [0, 100] that this is a true equivalence match.
1152    pub replay_match_confidence: u8,
1153}
1154
1155/// Reason code explaining the outcome of a semantic replay evaluation.
1156#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1157#[serde(rename_all = "snake_case")]
1158pub enum SemanticReplayReasonCode {
1159    /// Task matched a known equivalence class and replay is permitted.
1160    EquivalenceMatchApproved,
1161    /// Task is in an approved class but confidence is below the minimum threshold.
1162    LowConfidenceDenied,
1163    /// Task did not match any approved semantic equivalence class.
1164    NoEquivalenceClassMatch,
1165    /// Replay was not permitted because the equivalence class is not on the
1166    /// allowed list for the current risk tier.
1167    EquivalenceClassNotAllowed,
1168    /// Semantic evaluation failed with an unexpected state; fail closed.
1169    UnknownFailClosed,
1170}
1171
1172/// Decision produced by semantic replay evaluation.
1173///
1174/// Consumers must check `replay_decision` and `fail_closed` before attempting
1175/// any replay operation. When `fail_closed` is `true`, replay must not proceed
1176/// regardless of `replay_decision`.
1177#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1178pub struct SemanticReplayDecision {
1179    /// Unique identifier for this evaluation.
1180    pub evaluation_id: String,
1181    /// The task identifier being evaluated.
1182    pub task_id: String,
1183    /// Whether semantic replay is permitted for this task.
1184    pub replay_decision: bool,
1185    /// The equivalence explanation that drove the decision, if any.
1186    pub equivalence_explanation: Option<EquivalenceExplanation>,
1187    /// The reason code for this decision.
1188    pub reason_code: SemanticReplayReasonCode,
1189    /// Human-readable decision summary.
1190    pub summary: String,
1191    /// Safety gate. When `true`, replay must be blocked regardless of
1192    /// `replay_decision`.
1193    pub fail_closed: bool,
1194}
1195
1196/// Construct an approved `SemanticReplayDecision`.
1197pub fn approve_semantic_replay(
1198    evaluation_id: impl Into<String>,
1199    task_id: impl Into<String>,
1200    explanation: EquivalenceExplanation,
1201) -> SemanticReplayDecision {
1202    let summary = format!(
1203        "semantic replay approved [equivalence_class={:?}, confidence={}]",
1204        explanation.task_equivalence_class, explanation.replay_match_confidence
1205    );
1206    SemanticReplayDecision {
1207        evaluation_id: evaluation_id.into(),
1208        task_id: task_id.into(),
1209        replay_decision: true,
1210        reason_code: SemanticReplayReasonCode::EquivalenceMatchApproved,
1211        equivalence_explanation: Some(explanation),
1212        summary,
1213        fail_closed: false,
1214    }
1215}
1216
1217/// Construct a denied `SemanticReplayDecision`.
1218pub fn deny_semantic_replay(
1219    evaluation_id: impl Into<String>,
1220    task_id: impl Into<String>,
1221    reason_code: SemanticReplayReasonCode,
1222    context: impl Into<String>,
1223) -> SemanticReplayDecision {
1224    let context: String = context.into();
1225    let summary = format!("semantic replay denied [{reason_code:?}]: {context}");
1226    SemanticReplayDecision {
1227        evaluation_id: evaluation_id.into(),
1228        task_id: task_id.into(),
1229        replay_decision: false,
1230        equivalence_explanation: None,
1231        reason_code,
1232        summary,
1233        fail_closed: true,
1234    }
1235}
1236
1237// ──────────────────────────────────────────────────────────────────────────────
1238// AUTO-05: Continuous Confidence Revalidation and Asset Demotion
1239// ──────────────────────────────────────────────────────────────────────────────
1240
1241/// Current confidence lifecycle state of a reusable asset.
1242#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1243#[serde(rename_all = "snake_case")]
1244pub enum ConfidenceState {
1245    /// Asset is healthy and eligible for replay.
1246    Active,
1247    /// Asset confidence has decayed below the warning threshold; still eligible
1248    /// but flagged for revalidation.
1249    Decaying,
1250    /// Asset is undergoing shadow-mode replay revalidation; normal reuse
1251    /// continues but evidence is accumulated.
1252    Revalidating,
1253    /// Asset has been demoted after failed reuse; replay is suspended pending
1254    /// explicit re-promotion.
1255    Demoted,
1256    /// Asset is quarantined after repeated failures; replay is blocked until
1257    /// explicit triage clears it.
1258    Quarantined,
1259}
1260
1261/// Outcome of a single confidence revalidation round.
1262#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1263#[serde(rename_all = "snake_case")]
1264pub enum RevalidationOutcome {
1265    /// Revalidation completed and confidence was restored above threshold.
1266    Passed,
1267    /// Revalidation completed but confidence remains below threshold.
1268    Failed,
1269    /// Revalidation is still running (shadow phase not yet complete).
1270    Pending,
1271    /// Revalidation encountered an error; treat as failed for safety.
1272    ErrorFailClosed,
1273}
1274
1275/// Reason code for a demotion or quarantine transition.
1276#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1277#[serde(rename_all = "snake_case")]
1278pub enum ConfidenceDemotionReasonCode {
1279    /// Asset was demoted because confidence decayed below the demotion threshold.
1280    ConfidenceDecayThreshold,
1281    /// Asset was demoted due to repeated failed reuse (replay failures).
1282    RepeatedReplayFailure,
1283    /// Asset was quarantined after surpassing the maximum failure count.
1284    MaxFailureCountExceeded,
1285    /// Asset was revoked by explicit maintainer action.
1286    ExplicitRevocation,
1287    /// Demotion failed with an unmapped state; fail closed.
1288    UnknownFailClosed,
1289}
1290
1291/// Whether an asset is currently eligible for replay selection.
1292#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1293#[serde(rename_all = "snake_case")]
1294pub enum ReplayEligibility {
1295    /// Asset is eligible; replay may proceed.
1296    Eligible,
1297    /// Asset is not eligible; replay must not proceed.
1298    Ineligible,
1299}
1300
1301/// Full revalidation decision produced after a confidence evaluation round.
1302#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1303pub struct ConfidenceRevalidationResult {
1304    /// Unique identifier for this revalidation run.
1305    pub revalidation_id: String,
1306    /// The asset being revalidated (gene id or capsule id).
1307    pub asset_id: String,
1308    /// Current confidence state before this revalidation.
1309    pub confidence_state: ConfidenceState,
1310    /// Outcome of this revalidation round.
1311    pub revalidation_result: RevalidationOutcome,
1312    /// Whether replay is eligible after this evaluation.
1313    pub replay_eligibility: ReplayEligibility,
1314    /// Human-readable summary of the revalidation outcome.
1315    pub summary: String,
1316    /// Safety gate. When `true`, replay must not proceed regardless of
1317    /// `replay_eligibility`.
1318    pub fail_closed: bool,
1319}
1320
1321/// Demotion or quarantine transition event.
1322#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1323pub struct DemotionDecision {
1324    /// Unique identifier for this demotion event.
1325    pub demotion_id: String,
1326    /// The asset being demoted or quarantined.
1327    pub asset_id: String,
1328    /// Prior state before this transition.
1329    pub prior_state: ConfidenceState,
1330    /// New state after this transition.
1331    pub new_state: ConfidenceState,
1332    /// Reason for the demotion.
1333    pub reason_code: ConfidenceDemotionReasonCode,
1334    /// Replay eligibility after this transition.
1335    pub replay_eligibility: ReplayEligibility,
1336    /// Human-readable summary.
1337    pub summary: String,
1338    /// Whether this demotion resulted in a quarantine transition.
1339    pub quarantine_transition: bool,
1340    /// Safety gate. Always `true` for any demotion event.
1341    pub fail_closed: bool,
1342}
1343
1344/// Construct a passing `ConfidenceRevalidationResult`.
1345pub fn pass_confidence_revalidation(
1346    revalidation_id: impl Into<String>,
1347    asset_id: impl Into<String>,
1348    prior_state: ConfidenceState,
1349) -> ConfidenceRevalidationResult {
1350    let asset_id: String = asset_id.into();
1351    let summary =
1352        format!("confidence revalidation passed for asset {asset_id}: restoring to Active");
1353    ConfidenceRevalidationResult {
1354        revalidation_id: revalidation_id.into(),
1355        asset_id,
1356        confidence_state: ConfidenceState::Active,
1357        revalidation_result: RevalidationOutcome::Passed,
1358        replay_eligibility: ReplayEligibility::Eligible,
1359        summary,
1360        fail_closed: false,
1361    }
1362}
1363
1364/// Construct a failing `ConfidenceRevalidationResult`.
1365pub fn fail_confidence_revalidation(
1366    revalidation_id: impl Into<String>,
1367    asset_id: impl Into<String>,
1368    prior_state: ConfidenceState,
1369    outcome: RevalidationOutcome,
1370) -> ConfidenceRevalidationResult {
1371    let asset_id: String = asset_id.into();
1372    let summary = format!(
1373        "confidence revalidation failed for asset {asset_id} [{outcome:?}]: replay suspended"
1374    );
1375    ConfidenceRevalidationResult {
1376        revalidation_id: revalidation_id.into(),
1377        asset_id,
1378        confidence_state: prior_state,
1379        revalidation_result: outcome,
1380        replay_eligibility: ReplayEligibility::Ineligible,
1381        summary,
1382        fail_closed: true,
1383    }
1384}
1385
1386/// Construct a `DemotionDecision`.
1387pub fn demote_asset(
1388    demotion_id: impl Into<String>,
1389    asset_id: impl Into<String>,
1390    prior_state: ConfidenceState,
1391    new_state: ConfidenceState,
1392    reason_code: ConfidenceDemotionReasonCode,
1393) -> DemotionDecision {
1394    let asset_id: String = asset_id.into();
1395    let quarantine_transition = new_state == ConfidenceState::Quarantined;
1396    let summary =
1397        format!("asset {asset_id} demoted from {prior_state:?} to {new_state:?} [{reason_code:?}]");
1398    DemotionDecision {
1399        demotion_id: demotion_id.into(),
1400        asset_id,
1401        prior_state,
1402        new_state,
1403        reason_code,
1404        replay_eligibility: ReplayEligibility::Ineligible,
1405        summary,
1406        quarantine_transition,
1407        fail_closed: true,
1408    }
1409}
1410
1411#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1412#[serde(rename_all = "snake_case")]
1413pub enum SelfEvolutionSelectionReasonCode {
1414    Accepted,
1415    IssueClosed,
1416    MissingEvolutionLabel,
1417    MissingFeatureLabel,
1418    ExcludedByLabel,
1419    UnsupportedCandidateScope,
1420    UnknownFailClosed,
1421}
1422
1423#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1424pub struct SelfEvolutionSelectionDecision {
1425    pub issue_number: u64,
1426    pub selected: bool,
1427    #[serde(default, skip_serializing_if = "Option::is_none")]
1428    pub candidate_class: Option<BoundedTaskClass>,
1429    pub summary: String,
1430    #[serde(default, skip_serializing_if = "Option::is_none")]
1431    pub reason_code: Option<SelfEvolutionSelectionReasonCode>,
1432    #[serde(default, skip_serializing_if = "Option::is_none")]
1433    pub failure_reason: Option<String>,
1434    #[serde(default, skip_serializing_if = "Option::is_none")]
1435    pub recovery_hint: Option<String>,
1436    pub fail_closed: bool,
1437}
1438
1439#[derive(Clone, Copy)]
1440struct SelfEvolutionSelectionDefaults {
1441    failure_reason: &'static str,
1442    recovery_hint: &'static str,
1443}
1444
1445fn self_evolution_selection_defaults(
1446    reason_code: &SelfEvolutionSelectionReasonCode,
1447) -> Option<SelfEvolutionSelectionDefaults> {
1448    match reason_code {
1449        SelfEvolutionSelectionReasonCode::Accepted => None,
1450        SelfEvolutionSelectionReasonCode::IssueClosed => Some(SelfEvolutionSelectionDefaults {
1451            failure_reason: "self-evolution candidate rejected because the issue is closed",
1452            recovery_hint: "Reopen the issue or choose an active open issue before retrying selection.",
1453        }),
1454        SelfEvolutionSelectionReasonCode::MissingEvolutionLabel => {
1455            Some(SelfEvolutionSelectionDefaults {
1456                failure_reason: "self-evolution candidate rejected because the issue is missing area/evolution",
1457                recovery_hint:
1458                    "Add the area/evolution label or choose an issue already scoped to self-evolution.",
1459            })
1460        }
1461        SelfEvolutionSelectionReasonCode::MissingFeatureLabel => {
1462            Some(SelfEvolutionSelectionDefaults {
1463                failure_reason: "self-evolution candidate rejected because the issue is missing type/feature",
1464                recovery_hint:
1465                    "Add the type/feature label or narrow the issue to a bounded feature slice before retrying.",
1466            })
1467        }
1468        SelfEvolutionSelectionReasonCode::ExcludedByLabel => Some(SelfEvolutionSelectionDefaults {
1469            failure_reason: "self-evolution candidate rejected by an excluded issue label",
1470            recovery_hint:
1471                "Remove the excluded label or choose a non-duplicate, non-invalid, actionable issue.",
1472        }),
1473        SelfEvolutionSelectionReasonCode::UnsupportedCandidateScope => {
1474            Some(SelfEvolutionSelectionDefaults {
1475                failure_reason:
1476                    "self-evolution candidate rejected because the hinted file scope is outside the bounded docs policy",
1477                recovery_hint:
1478                    "Narrow candidate paths to the approved docs/*.md boundary before retrying selection.",
1479            })
1480        }
1481        SelfEvolutionSelectionReasonCode::UnknownFailClosed => Some(SelfEvolutionSelectionDefaults {
1482            failure_reason: "self-evolution candidate failed with an unmapped selection reason",
1483            recovery_hint: "Unknown selection failure; fail closed and require explicit maintainer triage before retry.",
1484        }),
1485    }
1486}
1487
1488pub fn accept_self_evolution_selection_decision(
1489    issue_number: u64,
1490    candidate_class: BoundedTaskClass,
1491    summary: Option<&str>,
1492) -> SelfEvolutionSelectionDecision {
1493    let summary = normalize_optional_text(summary).unwrap_or_else(|| {
1494        format!("selected GitHub issue #{issue_number} as a bounded self-evolution candidate")
1495    });
1496    SelfEvolutionSelectionDecision {
1497        issue_number,
1498        selected: true,
1499        candidate_class: Some(candidate_class),
1500        summary,
1501        reason_code: Some(SelfEvolutionSelectionReasonCode::Accepted),
1502        failure_reason: None,
1503        recovery_hint: None,
1504        fail_closed: false,
1505    }
1506}
1507
1508pub fn reject_self_evolution_selection_decision(
1509    issue_number: u64,
1510    reason_code: SelfEvolutionSelectionReasonCode,
1511    failure_reason: Option<&str>,
1512    summary: Option<&str>,
1513) -> SelfEvolutionSelectionDecision {
1514    let defaults = self_evolution_selection_defaults(&reason_code)
1515        .unwrap_or(SelfEvolutionSelectionDefaults {
1516        failure_reason: "self-evolution candidate rejected",
1517        recovery_hint:
1518            "Review candidate selection inputs and retry within the bounded self-evolution policy.",
1519    });
1520    let failure_reason = normalize_optional_text(failure_reason)
1521        .unwrap_or_else(|| defaults.failure_reason.to_string());
1522    let reason_code_key = match reason_code {
1523        SelfEvolutionSelectionReasonCode::Accepted => "accepted",
1524        SelfEvolutionSelectionReasonCode::IssueClosed => "issue_closed",
1525        SelfEvolutionSelectionReasonCode::MissingEvolutionLabel => "missing_evolution_label",
1526        SelfEvolutionSelectionReasonCode::MissingFeatureLabel => "missing_feature_label",
1527        SelfEvolutionSelectionReasonCode::ExcludedByLabel => "excluded_by_label",
1528        SelfEvolutionSelectionReasonCode::UnsupportedCandidateScope => {
1529            "unsupported_candidate_scope"
1530        }
1531        SelfEvolutionSelectionReasonCode::UnknownFailClosed => "unknown_fail_closed",
1532    };
1533    let summary = normalize_optional_text(summary).unwrap_or_else(|| {
1534        format!(
1535            "rejected GitHub issue #{issue_number} as a self-evolution candidate [{reason_code_key}]"
1536        )
1537    });
1538
1539    SelfEvolutionSelectionDecision {
1540        issue_number,
1541        selected: false,
1542        candidate_class: None,
1543        summary,
1544        reason_code: Some(reason_code),
1545        failure_reason: Some(failure_reason),
1546        recovery_hint: Some(defaults.recovery_hint.to_string()),
1547        fail_closed: true,
1548    }
1549}
1550
1551pub fn accept_discovered_candidate(
1552    dedupe_key: impl Into<String>,
1553    candidate_source: AutonomousCandidateSource,
1554    candidate_class: BoundedTaskClass,
1555    signals: Vec<String>,
1556    summary: Option<&str>,
1557) -> DiscoveredCandidate {
1558    let summary = normalize_optional_text(summary)
1559        .unwrap_or_else(|| format!("accepted autonomous candidate from {candidate_source:?}"));
1560    DiscoveredCandidate {
1561        dedupe_key: dedupe_key.into(),
1562        candidate_source,
1563        candidate_class: Some(candidate_class),
1564        signals,
1565        accepted: true,
1566        reason_code: AutonomousIntakeReasonCode::Accepted,
1567        summary,
1568        failure_reason: None,
1569        recovery_hint: None,
1570        fail_closed: false,
1571    }
1572}
1573
1574pub fn deny_discovered_candidate(
1575    dedupe_key: impl Into<String>,
1576    candidate_source: AutonomousCandidateSource,
1577    signals: Vec<String>,
1578    reason_code: AutonomousIntakeReasonCode,
1579) -> DiscoveredCandidate {
1580    let (failure_reason, recovery_hint) = match reason_code {
1581        AutonomousIntakeReasonCode::UnsupportedSignalClass => (
1582            "signal class is not supported by the bounded evolution policy",
1583            "review supported candidate signal classes and filter input before retry",
1584        ),
1585        AutonomousIntakeReasonCode::AmbiguousSignal => (
1586            "signals do not map to a unique bounded candidate class",
1587            "provide more specific signal tokens or triage manually before resubmitting",
1588        ),
1589        AutonomousIntakeReasonCode::DuplicateCandidate => (
1590            "an equivalent candidate has already been discovered in this intake window",
1591            "deduplicate signals before resubmitting or check the existing candidate queue",
1592        ),
1593        AutonomousIntakeReasonCode::UnknownFailClosed => (
1594            "candidate intake failed with an unmapped reason; fail closed",
1595            "require explicit maintainer triage before retry",
1596        ),
1597        AutonomousIntakeReasonCode::Accepted => (
1598            "unexpected accepted reason on deny path",
1599            "use accept_discovered_candidate for accepted outcomes",
1600        ),
1601    };
1602    let summary =
1603        format!("denied autonomous candidate from {candidate_source:?}: {failure_reason}");
1604    DiscoveredCandidate {
1605        dedupe_key: dedupe_key.into(),
1606        candidate_source,
1607        candidate_class: None,
1608        signals,
1609        accepted: false,
1610        reason_code,
1611        summary,
1612        failure_reason: Some(failure_reason.to_string()),
1613        recovery_hint: Some(recovery_hint.to_string()),
1614        fail_closed: true,
1615    }
1616}
1617
1618#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1619pub struct HumanApproval {
1620    pub approved: bool,
1621    pub approver: Option<String>,
1622    pub note: Option<String>,
1623}
1624
1625#[derive(Clone, Debug, Serialize, Deserialize)]
1626pub struct SupervisedDevloopRequest {
1627    pub task: AgentTask,
1628    pub proposal: MutationProposal,
1629    pub approval: HumanApproval,
1630}
1631
1632#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1633pub enum SupervisedDevloopStatus {
1634    AwaitingApproval,
1635    RejectedByPolicy,
1636    FailedClosed,
1637    Executed,
1638}
1639
1640#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1641#[serde(rename_all = "snake_case")]
1642pub enum SupervisedDeliveryStatus {
1643    Prepared,
1644    Denied,
1645}
1646
1647#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1648#[serde(rename_all = "snake_case")]
1649pub enum SupervisedDeliveryApprovalState {
1650    Approved,
1651    MissingExplicitApproval,
1652}
1653
1654#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1655#[serde(rename_all = "snake_case")]
1656pub enum SupervisedDeliveryReasonCode {
1657    DeliveryPrepared,
1658    AwaitingApproval,
1659    DeliveryEvidenceMissing,
1660    ValidationEvidenceMissing,
1661    UnsupportedTaskScope,
1662    InconsistentDeliveryEvidence,
1663    UnknownFailClosed,
1664}
1665
1666#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1667pub struct SupervisedDeliveryContract {
1668    pub delivery_summary: String,
1669    #[serde(default, skip_serializing_if = "Option::is_none")]
1670    pub branch_name: Option<String>,
1671    #[serde(default, skip_serializing_if = "Option::is_none")]
1672    pub pr_title: Option<String>,
1673    #[serde(default, skip_serializing_if = "Option::is_none")]
1674    pub pr_summary: Option<String>,
1675    pub delivery_status: SupervisedDeliveryStatus,
1676    pub approval_state: SupervisedDeliveryApprovalState,
1677    pub reason_code: SupervisedDeliveryReasonCode,
1678    #[serde(default)]
1679    pub fail_closed: bool,
1680    #[serde(default, skip_serializing_if = "Option::is_none")]
1681    pub recovery_hint: Option<String>,
1682}
1683
1684#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1685#[serde(rename_all = "snake_case")]
1686pub enum SupervisedExecutionDecision {
1687    AwaitingApproval,
1688    ReplayHit,
1689    PlannerFallback,
1690    RejectedByPolicy,
1691    FailedClosed,
1692}
1693
1694#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1695#[serde(rename_all = "snake_case")]
1696pub enum SupervisedValidationOutcome {
1697    NotRun,
1698    Passed,
1699    FailedClosed,
1700}
1701
1702#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1703#[serde(rename_all = "snake_case")]
1704pub enum SupervisedExecutionReasonCode {
1705    AwaitingHumanApproval,
1706    ReplayHit,
1707    ReplayFallback,
1708    PolicyDenied,
1709    ValidationFailed,
1710    UnsafePatch,
1711    Timeout,
1712    MutationPayloadMissing,
1713    UnknownFailClosed,
1714}
1715#[derive(Clone, Debug, Serialize, Deserialize)]
1716pub struct SupervisedDevloopOutcome {
1717    pub task_id: String,
1718    pub task_class: Option<BoundedTaskClass>,
1719    pub status: SupervisedDevloopStatus,
1720    pub execution_decision: SupervisedExecutionDecision,
1721    #[serde(default, skip_serializing_if = "Option::is_none")]
1722    pub replay_outcome: Option<ReplayFeedback>,
1723    #[serde(default, skip_serializing_if = "Option::is_none")]
1724    pub fallback_reason: Option<String>,
1725    pub validation_outcome: SupervisedValidationOutcome,
1726    pub evidence_summary: String,
1727    #[serde(default, skip_serializing_if = "Option::is_none")]
1728    pub reason_code: Option<SupervisedExecutionReasonCode>,
1729    #[serde(default, skip_serializing_if = "Option::is_none")]
1730    pub recovery_hint: Option<String>,
1731    pub execution_feedback: Option<ExecutionFeedback>,
1732    #[serde(default, skip_serializing_if = "Option::is_none")]
1733    pub failure_contract: Option<MutationNeededFailureContract>,
1734    pub summary: String,
1735}
1736
1737#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1738#[serde(rename_all = "snake_case")]
1739pub enum SelfEvolutionAuditConsistencyResult {
1740    Consistent,
1741    Inconsistent,
1742}
1743
1744#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1745#[serde(rename_all = "snake_case")]
1746pub enum SelfEvolutionAcceptanceGateReasonCode {
1747    Accepted,
1748    MissingSelectionEvidence,
1749    MissingProposalEvidence,
1750    MissingApprovalEvidence,
1751    MissingExecutionEvidence,
1752    MissingDeliveryEvidence,
1753    InconsistentReasonCodeMatrix,
1754    UnknownFailClosed,
1755}
1756
1757#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1758pub struct SelfEvolutionApprovalEvidence {
1759    pub approval_required: bool,
1760    pub approved: bool,
1761    #[serde(default, skip_serializing_if = "Option::is_none")]
1762    pub approver: Option<String>,
1763}
1764
1765#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1766pub struct SelfEvolutionDeliveryOutcome {
1767    pub delivery_status: SupervisedDeliveryStatus,
1768    pub approval_state: SupervisedDeliveryApprovalState,
1769    pub reason_code: SupervisedDeliveryReasonCode,
1770}
1771
1772#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1773pub struct SelfEvolutionReasonCodeMatrix {
1774    #[serde(default, skip_serializing_if = "Option::is_none")]
1775    pub selection_reason_code: Option<SelfEvolutionSelectionReasonCode>,
1776    pub proposal_reason_code: MutationProposalContractReasonCode,
1777    #[serde(default, skip_serializing_if = "Option::is_none")]
1778    pub execution_reason_code: Option<SupervisedExecutionReasonCode>,
1779    pub delivery_reason_code: SupervisedDeliveryReasonCode,
1780}
1781
1782#[derive(Clone, Debug, Serialize, Deserialize)]
1783pub struct SelfEvolutionAcceptanceGateInput {
1784    pub selection_decision: SelfEvolutionSelectionDecision,
1785    pub proposal_contract: SelfEvolutionMutationProposalContract,
1786    pub supervised_request: SupervisedDevloopRequest,
1787    pub execution_outcome: SupervisedDevloopOutcome,
1788    pub delivery_contract: SupervisedDeliveryContract,
1789}
1790
1791#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1792pub struct SelfEvolutionAcceptanceGateContract {
1793    pub acceptance_gate_summary: String,
1794    pub audit_consistency_result: SelfEvolutionAuditConsistencyResult,
1795    pub approval_evidence: SelfEvolutionApprovalEvidence,
1796    pub delivery_outcome: SelfEvolutionDeliveryOutcome,
1797    pub reason_code_matrix: SelfEvolutionReasonCodeMatrix,
1798    pub fail_closed: bool,
1799    pub reason_code: SelfEvolutionAcceptanceGateReasonCode,
1800    #[serde(default, skip_serializing_if = "Option::is_none")]
1801    pub recovery_hint: Option<String>,
1802}
1803
1804#[cfg(test)]
1805mod tests {
1806    use super::*;
1807
1808    fn handshake_request_with_versions(versions: &[&str]) -> A2aHandshakeRequest {
1809        A2aHandshakeRequest {
1810            agent_id: "agent-test".into(),
1811            role: AgentRole::Planner,
1812            capability_level: AgentCapabilityLevel::A2,
1813            supported_protocols: versions
1814                .iter()
1815                .map(|version| A2aProtocol {
1816                    name: A2A_PROTOCOL_NAME.into(),
1817                    version: (*version).into(),
1818                })
1819                .collect(),
1820            advertised_capabilities: vec![A2aCapability::Coordination],
1821        }
1822    }
1823
1824    #[test]
1825    fn negotiate_supported_protocol_prefers_v1_when_available() {
1826        let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION, A2A_PROTOCOL_VERSION_V1]);
1827        let negotiated = req
1828            .negotiate_supported_protocol()
1829            .expect("expected protocol negotiation success");
1830        assert_eq!(negotiated.name, A2A_PROTOCOL_NAME);
1831        assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION_V1);
1832    }
1833
1834    #[test]
1835    fn negotiate_supported_protocol_falls_back_to_experimental() {
1836        let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION]);
1837        let negotiated = req
1838            .negotiate_supported_protocol()
1839            .expect("expected protocol negotiation success");
1840        assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION);
1841    }
1842
1843    #[test]
1844    fn negotiate_supported_protocol_returns_none_without_overlap() {
1845        let req = handshake_request_with_versions(&["0.0.1"]);
1846        assert!(req.negotiate_supported_protocol().is_none());
1847    }
1848
1849    #[test]
1850    fn normalize_replay_fallback_contract_maps_known_reason() {
1851        let contract = normalize_replay_fallback_contract(
1852            &ReplayPlannerDirective::PlanFallback,
1853            Some("no matching gene"),
1854            None,
1855            None,
1856            None,
1857            None,
1858        )
1859        .expect("contract should exist");
1860
1861        assert_eq!(
1862            contract.reason_code,
1863            ReplayFallbackReasonCode::NoCandidateAfterSelect
1864        );
1865        assert_eq!(
1866            contract.next_action,
1867            ReplayFallbackNextAction::PlanFromScratch
1868        );
1869        assert_eq!(contract.confidence, 92);
1870    }
1871
1872    #[test]
1873    fn normalize_replay_fallback_contract_fails_closed_for_unknown_reason() {
1874        let contract = normalize_replay_fallback_contract(
1875            &ReplayPlannerDirective::PlanFallback,
1876            Some("something unexpected"),
1877            None,
1878            None,
1879            None,
1880            None,
1881        )
1882        .expect("contract should exist");
1883
1884        assert_eq!(
1885            contract.reason_code,
1886            ReplayFallbackReasonCode::UnmappedFallbackReason
1887        );
1888        assert_eq!(
1889            contract.next_action,
1890            ReplayFallbackNextAction::EscalateFailClosed
1891        );
1892        assert_eq!(contract.confidence, 0);
1893    }
1894
1895    #[test]
1896    fn normalize_replay_fallback_contract_rejects_conflicting_next_action() {
1897        let contract = normalize_replay_fallback_contract(
1898            &ReplayPlannerDirective::PlanFallback,
1899            Some("replay validation failed"),
1900            Some(ReplayFallbackReasonCode::ValidationFailed),
1901            None,
1902            Some(ReplayFallbackNextAction::PlanFromScratch),
1903            Some(88),
1904        )
1905        .expect("contract should exist");
1906
1907        assert_eq!(
1908            contract.reason_code,
1909            ReplayFallbackReasonCode::UnmappedFallbackReason
1910        );
1911        assert_eq!(
1912            contract.next_action,
1913            ReplayFallbackNextAction::EscalateFailClosed
1914        );
1915        assert_eq!(contract.confidence, 0);
1916    }
1917
1918    #[test]
1919    fn normalize_mutation_needed_failure_contract_maps_policy_denied() {
1920        let contract = normalize_mutation_needed_failure_contract(
1921            Some("supervised devloop rejected task because it is outside bounded scope"),
1922            None,
1923        );
1924
1925        assert_eq!(
1926            contract.reason_code,
1927            MutationNeededFailureReasonCode::PolicyDenied
1928        );
1929        assert_eq!(
1930            contract.recovery_action,
1931            MutationNeededRecoveryAction::NarrowScopeAndRetry
1932        );
1933        assert!(contract.fail_closed);
1934    }
1935
1936    #[test]
1937    fn normalize_mutation_needed_failure_contract_maps_timeout() {
1938        let contract = normalize_mutation_needed_failure_contract(
1939            Some("command timed out: git apply --check patch.diff"),
1940            None,
1941        );
1942
1943        assert_eq!(
1944            contract.reason_code,
1945            MutationNeededFailureReasonCode::Timeout
1946        );
1947        assert_eq!(
1948            contract.recovery_action,
1949            MutationNeededRecoveryAction::ReduceExecutionBudget
1950        );
1951        assert!(contract.fail_closed);
1952    }
1953
1954    #[test]
1955    fn normalize_mutation_needed_failure_contract_fails_closed_for_unknown_reason() {
1956        let contract =
1957            normalize_mutation_needed_failure_contract(Some("unexpected runner panic"), None);
1958
1959        assert_eq!(
1960            contract.reason_code,
1961            MutationNeededFailureReasonCode::UnknownFailClosed
1962        );
1963        assert_eq!(
1964            contract.recovery_action,
1965            MutationNeededRecoveryAction::EscalateFailClosed
1966        );
1967        assert!(contract.fail_closed);
1968    }
1969
1970    #[test]
1971    fn reject_self_evolution_selection_decision_maps_closed_issue_defaults() {
1972        let decision = reject_self_evolution_selection_decision(
1973            234,
1974            SelfEvolutionSelectionReasonCode::IssueClosed,
1975            None,
1976            None,
1977        );
1978
1979        assert!(!decision.selected);
1980        assert_eq!(decision.issue_number, 234);
1981        assert_eq!(
1982            decision.reason_code,
1983            Some(SelfEvolutionSelectionReasonCode::IssueClosed)
1984        );
1985        assert!(decision.fail_closed);
1986        assert!(decision
1987            .failure_reason
1988            .as_deref()
1989            .is_some_and(|reason| reason.contains("closed")));
1990        assert!(decision.recovery_hint.is_some());
1991    }
1992
1993    #[test]
1994    fn accept_self_evolution_selection_decision_marks_candidate_selected() {
1995        let decision =
1996            accept_self_evolution_selection_decision(235, BoundedTaskClass::DocsSingleFile, None);
1997
1998        assert!(decision.selected);
1999        assert_eq!(decision.issue_number, 235);
2000        assert_eq!(
2001            decision.candidate_class,
2002            Some(BoundedTaskClass::DocsSingleFile)
2003        );
2004        assert_eq!(
2005            decision.reason_code,
2006            Some(SelfEvolutionSelectionReasonCode::Accepted)
2007        );
2008        assert!(!decision.fail_closed);
2009        assert_eq!(decision.failure_reason, None);
2010        assert_eq!(decision.recovery_hint, None);
2011    }
2012}
2013
2014/// Hub trust tier - defines operational permissions for a Hub
2015#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
2016pub enum HubTrustTier {
2017    /// Full trust - allows all operations (internal/private Hub)
2018    Full,
2019    /// Read-only - allows only read operations (public Hub)
2020    ReadOnly,
2021}
2022
2023/// Hub operation class - classifies the type of A2A operation
2024#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
2025pub enum HubOperationClass {
2026    Hello,
2027    Fetch,
2028    Publish,
2029    Revoke,
2030    TaskClaim,
2031    TaskComplete,
2032    WorkerRegister,
2033    Recipe,
2034    Session,
2035    Dispute,
2036    Swarm,
2037}
2038
2039impl HubOperationClass {
2040    /// Returns true if the operation is read-only (allowed for ReadOnly hubs)
2041    pub fn is_read_only(&self) -> bool {
2042        matches!(self, HubOperationClass::Hello | HubOperationClass::Fetch)
2043    }
2044}
2045
2046/// Hub profile - describes a Hub's capabilities and configuration
2047#[derive(Clone, Debug, Serialize, Deserialize)]
2048pub struct HubProfile {
2049    pub hub_id: String,
2050    pub base_url: String,
2051    pub trust_tier: HubTrustTier,
2052    /// Priority for hub selection (higher = preferred)
2053    pub priority: u32,
2054    /// Optional health check endpoint
2055    pub health_url: Option<String>,
2056}
2057
2058impl HubProfile {
2059    /// Check if this hub allows the given operation class
2060    pub fn allows_operation(&self, operation: &HubOperationClass) -> bool {
2061        match &self.trust_tier {
2062            HubTrustTier::Full => true,
2063            HubTrustTier::ReadOnly => operation.is_read_only(),
2064        }
2065    }
2066}
2067
2068/// Hub selection policy - defines how to choose between multiple hubs
2069#[derive(Clone, Debug)]
2070pub struct HubSelectionPolicy {
2071    /// Map operation class to allowed trust tiers
2072    pub allowed_tiers_for_operation: Vec<(HubOperationClass, Vec<HubTrustTier>)>,
2073    /// Default trust tiers if no specific mapping
2074    pub default_allowed_tiers: Vec<HubTrustTier>,
2075}
2076
2077impl Default for HubSelectionPolicy {
2078    fn default() -> Self {
2079        Self {
2080            allowed_tiers_for_operation: vec![
2081                (
2082                    HubOperationClass::Hello,
2083                    vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
2084                ),
2085                (
2086                    HubOperationClass::Fetch,
2087                    vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
2088                ),
2089                // All write operations require Full trust
2090                (HubOperationClass::Publish, vec![HubTrustTier::Full]),
2091                (HubOperationClass::Revoke, vec![HubTrustTier::Full]),
2092                (HubOperationClass::TaskClaim, vec![HubTrustTier::Full]),
2093                (HubOperationClass::TaskComplete, vec![HubTrustTier::Full]),
2094                (HubOperationClass::WorkerRegister, vec![HubTrustTier::Full]),
2095                (HubOperationClass::Recipe, vec![HubTrustTier::Full]),
2096                (HubOperationClass::Session, vec![HubTrustTier::Full]),
2097                (HubOperationClass::Dispute, vec![HubTrustTier::Full]),
2098                (HubOperationClass::Swarm, vec![HubTrustTier::Full]),
2099            ],
2100            default_allowed_tiers: vec![HubTrustTier::Full],
2101        }
2102    }
2103}
2104
2105impl HubSelectionPolicy {
2106    /// Get allowed trust tiers for a given operation
2107    pub fn allowed_tiers(&self, operation: &HubOperationClass) -> &[HubTrustTier] {
2108        self.allowed_tiers_for_operation
2109            .iter()
2110            .find(|(op, _)| op == operation)
2111            .map(|(_, tiers)| tiers.as_slice())
2112            .unwrap_or(&self.default_allowed_tiers)
2113    }
2114}