use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::artifact_kind::ArtifactKind;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Goal {
pub goal_id: String,
pub title: String,
pub objective: String,
pub success_criteria: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraints: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_goal_title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Iteration {
pub iteration_id: String,
pub sequence: u32,
pub workspace_ref: WorkspaceRef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceRef {
#[serde(rename = "type")]
pub ref_type: String,
#[serde(rename = "ref")]
pub ref_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentIdentity {
pub agent_id: String,
pub agent_type: String,
pub constitution_id: String,
pub capability_manifest_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub orchestrator_run_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Summary {
pub what_changed: String,
pub why: String,
pub impact: String,
pub rollback_plan: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub open_questions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub alternatives_considered: Vec<DesignAlternative>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesignAlternative {
pub option: String,
pub rationale: String,
#[serde(default)]
pub chosen: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Changes {
pub artifacts: Vec<Artifact>,
pub patch_sets: Vec<PatchSet>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pending_actions: Vec<PendingAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingAction {
pub action_id: Uuid,
pub tool_name: String,
pub parameters: serde_json::Value,
pub kind: ActionKind,
pub intercepted_at: DateTime<Utc>,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_uri: Option<String>,
#[serde(default)]
pub disposition: ArtifactDisposition,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ActionKind {
ReadOnly,
StateChanging,
Unclassified,
}
impl fmt::Display for ActionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ActionKind::ReadOnly => write!(f, "read-only"),
ActionKind::StateChanging => write!(f, "state-changing"),
ActionKind::Unclassified => write!(f, "unclassified"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExplanationTiers {
pub summary: String,
pub explanation: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub related_artifacts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
pub resource_uri: String,
pub change_type: ChangeType,
pub diff_ref: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tests_run: Vec<String>,
#[serde(default)]
pub disposition: ArtifactDisposition,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<ChangeDependency>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation_tiers: Option<ExplanationTiers>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comments: Option<crate::review_session::CommentThread>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amendment: Option<AmendmentRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<ArtifactKind>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AmendmentRecord {
pub amended_by: String,
pub amended_at: DateTime<Utc>,
pub amendment_type: AmendmentType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AmendmentType {
FileReplaced,
PatchApplied,
Dropped,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactDisposition {
#[default]
Pending,
Approved,
Rejected,
Discuss,
}
impl fmt::Display for ArtifactDisposition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ArtifactDisposition::Pending => write!(f, "pending"),
ArtifactDisposition::Approved => write!(f, "approved"),
ArtifactDisposition::Rejected => write!(f, "rejected"),
ArtifactDisposition::Discuss => write!(f, "discuss"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeDependency {
pub target_uri: String,
pub kind: DependencyKind,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DependencyKind {
DependsOn,
DependedBy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChangeType {
Add,
Modify,
Delete,
Rename,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatchSet {
pub patch_set_id: String,
pub target_uri: String,
pub action: PatchAction,
pub preview_ref: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_intent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PatchAction {
WritePatch,
CreateDraft,
LabelChange,
PermissionChange,
DbPatch,
SchedulePost,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Risk {
pub risk_score: u32,
pub findings: Vec<RiskFinding>,
pub policy_decisions: Vec<PolicyDecisionRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskFinding {
pub category: RiskCategory,
pub severity: Severity,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence_refs: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mitigation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RiskCategory {
Pii,
Secrets,
Exfiltration,
ExternalComm,
PromptInjection,
PolicyViolation,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyDecisionRecord {
pub rule_id: String,
pub effect: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub grants_checked: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matching_grant: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evaluation_steps: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provenance {
pub inputs: Vec<ProvenanceInput>,
pub tool_trace_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvenanceInput {
pub source_type: String,
#[serde(rename = "ref")]
pub ref_uri: String,
pub trust_level: TrustLevel,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TrustLevel {
Trusted,
Untrusted,
Quarantined,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewRequests {
pub requested_actions: Vec<RequestedAction>,
pub reviewers: Vec<String>,
#[serde(default = "default_required_approvals")]
pub required_approvals: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes_to_reviewer: Option<String>,
}
fn default_required_approvals() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestedAction {
pub action: String,
pub targets: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signatures {
pub package_hash: String,
pub agent_signature: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub gateway_attestation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRecord {
pub reviewer: String,
pub approved_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DraftPackage {
pub package_version: String,
pub package_id: Uuid,
pub created_at: DateTime<Utc>,
pub goal: Goal,
pub iteration: Iteration,
pub agent_identity: AgentIdentity,
pub summary: Summary,
pub plan: Plan,
pub changes: Changes,
pub risk: Risk,
pub provenance: Provenance,
pub review_requests: ReviewRequests,
pub signatures: Signatures,
#[serde(default)]
pub status: DraftStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub verification_warnings: Vec<VerificationWarning>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub validation_log: Vec<ValidationEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vcs_status: Option<VcsTrackingInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_draft_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pending_approvals: Vec<ApprovalRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supervisor_review: Option<crate::supervisor_review::SupervisorReview>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignored_artifacts: Vec<IgnoredArtifact>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub baseline_artifacts: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agent_decision_log: Vec<DecisionLogEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal_shortref: Option<String>,
#[serde(default)]
pub draft_seq: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan_phase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VcsTrackingInfo {
pub branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_state: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit_sha: Option<String>,
pub last_checked: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationWarning {
pub command: String,
pub exit_code: Option<i32>,
pub output: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IgnoredArtifact {
pub path: String,
pub known_safe: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ValidationEntry {
pub command: String,
pub exit_code: i32,
pub duration_secs: u64,
pub stdout_tail: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
pub completed_steps: Vec<String>,
pub next_steps: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub decision_log: Vec<DecisionLogEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionLogEntry {
pub decision: String,
pub rationale: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub alternatives: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub alternatives_considered: Vec<AlternativeConsidered>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlternativeConsidered {
pub description: String,
pub rejected_reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(tag = "via", rename_all = "snake_case")]
pub enum ApplyProvenance {
#[default]
Manual,
BackgroundTask { task_id: String },
AutoMerge,
}
impl std::fmt::Display for ApplyProvenance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApplyProvenance::Manual => write!(f, "manual"),
ApplyProvenance::BackgroundTask { .. } => write!(f, "background"),
ApplyProvenance::AutoMerge => write!(f, "auto-merge"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum DraftStatus {
#[default]
Draft,
PendingReview,
Approved {
approved_by: String,
approved_at: DateTime<Utc>,
},
Denied {
reason: String,
denied_by: String,
},
Applied {
applied_at: DateTime<Utc>,
#[serde(default)]
applied_via: ApplyProvenance,
},
Superseded {
superseded_by: Uuid,
},
Closed {
closed_at: DateTime<Utc>,
reason: Option<String>,
closed_by: String,
},
}
impl std::fmt::Display for DraftStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DraftStatus::Draft => write!(f, "draft"),
DraftStatus::PendingReview => write!(f, "pending_review"),
DraftStatus::Approved { .. } => write!(f, "approved"),
DraftStatus::Denied { .. } => write!(f, "denied"),
DraftStatus::Applied { .. } => write!(f, "applied"),
DraftStatus::Superseded { .. } => write!(f, "superseded"),
DraftStatus::Closed { .. } => write!(f, "closed"),
}
}
}
#[cfg(test)]
pub fn make_test_pkg(goal_shortref: &str, draft_seq: u32) -> DraftPackage {
DraftPackage {
package_version: "1.0.0".to_string(),
package_id: Uuid::new_v4(),
created_at: chrono::Utc::now(),
goal: Goal {
goal_id: format!("{}-0000-0000-0000-000000000000", goal_shortref),
title: format!("Test goal {}", goal_shortref),
objective: "test".to_string(),
success_criteria: vec![],
constraints: vec![],
parent_goal_title: None,
},
iteration: Iteration {
iteration_id: "iter-1".to_string(),
sequence: 1,
workspace_ref: WorkspaceRef {
ref_type: "staging_dir".to_string(),
ref_name: "staging/test".to_string(),
base_ref: None,
},
},
agent_identity: AgentIdentity {
agent_id: "test-agent".to_string(),
agent_type: "test".to_string(),
constitution_id: "default".to_string(),
capability_manifest_hash: "abc".to_string(),
orchestrator_run_id: None,
},
summary: Summary {
what_changed: "test".to_string(),
why: "test".to_string(),
impact: "none".to_string(),
rollback_plan: "none".to_string(),
open_questions: vec![],
alternatives_considered: vec![],
},
plan: Plan {
completed_steps: vec![],
next_steps: vec![],
decision_log: vec![],
},
changes: Changes {
artifacts: vec![],
patch_sets: vec![],
pending_actions: vec![],
},
risk: Risk {
risk_score: 0,
findings: vec![],
policy_decisions: vec![],
},
provenance: Provenance {
inputs: vec![],
tool_trace_hash: "test".to_string(),
},
review_requests: ReviewRequests {
requested_actions: vec![],
reviewers: vec![],
required_approvals: 1,
notes_to_reviewer: None,
},
signatures: Signatures {
package_hash: "test".to_string(),
agent_signature: "test".to_string(),
gateway_attestation: None,
},
status: DraftStatus::PendingReview,
verification_warnings: vec![],
validation_log: vec![],
display_id: None,
tag: None,
vcs_status: None,
parent_draft_id: None,
pending_approvals: vec![],
supervisor_review: None,
ignored_artifacts: vec![],
baseline_artifacts: vec![],
agent_decision_log: vec![],
goal_shortref: Some(goal_shortref.to_string()),
draft_seq,
plan_phase: None,
}
}
pub fn check_missing_decisions(pkg: &DraftPackage) -> Option<String> {
if !pkg.agent_decision_log.is_empty() {
return None;
}
let substantive_extensions = [
"rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "cpp", "c", "h",
];
let has_substantive_code = pkg.changes.artifacts.iter().any(|a| {
let uri = &a.resource_uri;
if let Some(path_part) = uri.strip_prefix("fs://workspace/") {
let ext = std::path::Path::new(path_part)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
substantive_extensions.contains(&ext)
} else {
false
}
});
if has_substantive_code {
Some(
"No agent decision log entries found for a goal with significant code changes. \
Consider `ta run --follow-up` to capture design rationale before approving."
.to_string(),
)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_package() -> DraftPackage {
DraftPackage {
package_version: "1.0.0".to_string(),
package_id: Uuid::new_v4(),
created_at: Utc::now(),
goal: Goal {
goal_id: "goal-1".to_string(),
title: "Test Goal".to_string(),
objective: "Test the system".to_string(),
success_criteria: vec!["tests pass".to_string()],
constraints: vec![],
parent_goal_title: None,
},
iteration: Iteration {
iteration_id: "iter-1".to_string(),
sequence: 1,
workspace_ref: WorkspaceRef {
ref_type: "staging_dir".to_string(),
ref_name: "staging/goal-1/1".to_string(),
base_ref: None,
},
},
agent_identity: AgentIdentity {
agent_id: "agent-1".to_string(),
agent_type: "research".to_string(),
constitution_id: "default".to_string(),
capability_manifest_hash: "abc123".to_string(),
orchestrator_run_id: None,
},
summary: Summary {
what_changed: "Added test file".to_string(),
why: "To verify the system works".to_string(),
impact: "No production impact".to_string(),
rollback_plan: "Delete the file".to_string(),
open_questions: vec![],
alternatives_considered: vec![],
},
plan: Plan {
completed_steps: vec!["Created file".to_string()],
next_steps: vec![],
decision_log: vec![],
},
changes: Changes {
artifacts: vec![Artifact {
resource_uri: "fs://workspace/test.txt".to_string(),
change_type: ChangeType::Add,
diff_ref: "diff-001".to_string(),
tests_run: vec![],
disposition: Default::default(),
rationale: None,
dependencies: vec![],
explanation_tiers: None,
comments: None,
amendment: None,
kind: None,
}],
patch_sets: vec![],
pending_actions: vec![],
},
risk: Risk {
risk_score: 10,
findings: vec![],
policy_decisions: vec![],
},
provenance: Provenance {
inputs: vec![],
tool_trace_hash: "trace-hash-123".to_string(),
},
review_requests: ReviewRequests {
requested_actions: vec![RequestedAction {
action: "merge".to_string(),
targets: vec!["fs://workspace/test.txt".to_string()],
}],
reviewers: vec!["human-reviewer".to_string()],
required_approvals: 1,
notes_to_reviewer: None,
},
signatures: Signatures {
package_hash: "pkg-hash-456".to_string(),
agent_signature: "sig-789".to_string(),
gateway_attestation: None,
},
status: DraftStatus::Draft,
verification_warnings: vec![],
validation_log: vec![],
display_id: None,
tag: None,
vcs_status: None,
parent_draft_id: None,
pending_approvals: vec![],
supervisor_review: None,
ignored_artifacts: vec![],
baseline_artifacts: vec![],
agent_decision_log: vec![],
goal_shortref: None,
draft_seq: 0,
plan_phase: None,
}
}
#[test]
fn draft_package_serialization_round_trip() {
let pkg = test_package();
let json = serde_json::to_string_pretty(&pkg).unwrap();
let restored: DraftPackage = serde_json::from_str(&json).unwrap();
assert_eq!(pkg.package_id, restored.package_id);
assert_eq!(pkg.package_version, restored.package_version);
assert_eq!(pkg.goal.goal_id, restored.goal.goal_id);
assert_eq!(
pkg.changes.artifacts.len(),
restored.changes.artifacts.len()
);
}
#[test]
fn draft_status_transitions() {
let status = DraftStatus::PendingReview;
assert_eq!(status.to_string(), "pending_review");
let status = DraftStatus::Approved {
approved_by: "reviewer".to_string(),
approved_at: Utc::now(),
};
assert_eq!(status.to_string(), "approved");
let status = DraftStatus::Denied {
reason: "needs changes".to_string(),
denied_by: "reviewer".to_string(),
};
assert_eq!(status.to_string(), "denied");
let status = DraftStatus::Applied {
applied_at: Utc::now(),
applied_via: ApplyProvenance::Manual,
};
assert_eq!(status.to_string(), "applied");
}
#[test]
fn draft_status_default_is_draft() {
let status = DraftStatus::default();
assert_eq!(status, DraftStatus::Draft);
}
#[test]
fn draft_package_json_contains_required_fields() {
let pkg = test_package();
let json = serde_json::to_string(&pkg).unwrap();
assert!(json.contains("\"package_version\""));
assert!(json.contains("\"package_id\""));
assert!(json.contains("\"created_at\""));
assert!(json.contains("\"goal\""));
assert!(json.contains("\"iteration\""));
assert!(json.contains("\"agent_identity\""));
assert!(json.contains("\"summary\""));
assert!(json.contains("\"changes\""));
assert!(json.contains("\"risk\""));
assert!(json.contains("\"provenance\""));
assert!(json.contains("\"review_requests\""));
assert!(json.contains("\"signatures\""));
}
#[test]
fn risk_finding_serialization() {
let finding = RiskFinding {
category: RiskCategory::Secrets,
severity: Severity::High,
description: "API key detected in file".to_string(),
evidence_refs: vec!["line 42".to_string()],
mitigation: Some("Remove the key".to_string()),
};
let json = serde_json::to_string(&finding).unwrap();
assert!(json.contains("\"secrets\""));
assert!(json.contains("\"high\""));
}
#[test]
fn change_type_serialization() {
assert_eq!(serde_json::to_string(&ChangeType::Add).unwrap(), "\"add\"");
assert_eq!(
serde_json::to_string(&ChangeType::Modify).unwrap(),
"\"modify\""
);
}
#[test]
fn artifact_disposition_default_is_pending() {
let d = ArtifactDisposition::default();
assert_eq!(d, ArtifactDisposition::Pending);
assert_eq!(d.to_string(), "pending");
}
#[test]
fn artifact_disposition_serialization() {
assert_eq!(
serde_json::to_string(&ArtifactDisposition::Approved).unwrap(),
"\"approved\""
);
assert_eq!(
serde_json::to_string(&ArtifactDisposition::Rejected).unwrap(),
"\"rejected\""
);
assert_eq!(
serde_json::to_string(&ArtifactDisposition::Discuss).unwrap(),
"\"discuss\""
);
}
#[test]
fn artifact_with_disposition_round_trip() {
let artifact = Artifact {
resource_uri: "fs://workspace/src/main.rs".to_string(),
change_type: ChangeType::Modify,
diff_ref: "changeset:0".to_string(),
tests_run: vec![],
disposition: ArtifactDisposition::Approved,
rationale: Some("Fixed the bug".to_string()),
dependencies: vec![ChangeDependency {
target_uri: "fs://workspace/src/lib.rs".to_string(),
kind: DependencyKind::DependsOn,
}],
explanation_tiers: None,
comments: None,
amendment: None,
kind: None,
};
let json = serde_json::to_string(&artifact).unwrap();
let restored: Artifact = serde_json::from_str(&json).unwrap();
assert_eq!(restored.disposition, ArtifactDisposition::Approved);
assert_eq!(restored.rationale, Some("Fixed the bug".to_string()));
assert_eq!(restored.dependencies.len(), 1);
assert_eq!(restored.dependencies[0].kind, DependencyKind::DependsOn);
}
#[test]
fn artifact_without_new_fields_deserializes_with_defaults() {
let json = r#"{
"resource_uri": "fs://workspace/test.txt",
"change_type": "add",
"diff_ref": "changeset:0"
}"#;
let artifact: Artifact = serde_json::from_str(json).unwrap();
assert_eq!(artifact.disposition, ArtifactDisposition::Pending);
assert!(artifact.rationale.is_none());
assert!(artifact.dependencies.is_empty());
}
#[test]
fn dependency_kind_serialization() {
assert_eq!(
serde_json::to_string(&DependencyKind::DependsOn).unwrap(),
"\"depends_on\""
);
assert_eq!(
serde_json::to_string(&DependencyKind::DependedBy).unwrap(),
"\"depended_by\""
);
}
#[test]
fn draft_status_superseded_serialization() {
let superseding_id = Uuid::new_v4();
let status = DraftStatus::Superseded {
superseded_by: superseding_id,
};
assert_eq!(status.to_string(), "superseded");
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"superseded\""));
assert!(json.contains(&superseding_id.to_string()));
let restored: DraftStatus = serde_json::from_str(&json).unwrap();
assert_eq!(restored, status);
}
#[test]
fn draft_status_closed_serialization() {
let status = DraftStatus::Closed {
closed_at: Utc::now(),
reason: Some("Hand-merged upstream".to_string()),
closed_by: "human-reviewer".to_string(),
};
assert_eq!(status.to_string(), "closed");
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"closed\""));
assert!(json.contains("Hand-merged upstream"));
let restored: DraftStatus = serde_json::from_str(&json).unwrap();
assert_eq!(restored, status);
}
#[test]
fn draft_status_closed_without_reason() {
let status = DraftStatus::Closed {
closed_at: Utc::now(),
reason: None,
closed_by: "human-reviewer".to_string(),
};
let json = serde_json::to_string(&status).unwrap();
let restored: DraftStatus = serde_json::from_str(&json).unwrap();
assert_eq!(restored, status);
}
#[test]
fn explanation_tiers_serialization() {
let tiers = ExplanationTiers {
summary: "Refactored auth middleware to use JWT".to_string(),
explanation: "Replaced session-based auth with JWT validation.".to_string(),
tags: vec!["security".to_string(), "breaking-change".to_string()],
related_artifacts: vec![
"fs://workspace/src/auth/config.rs".to_string(),
"fs://workspace/tests/auth_test.rs".to_string(),
],
};
let json = serde_json::to_string(&tiers).unwrap();
assert!(json.contains("\"summary\""));
assert!(json.contains("\"explanation\""));
assert!(json.contains("\"tags\""));
assert!(json.contains("\"security\""));
let restored: ExplanationTiers = serde_json::from_str(&json).unwrap();
assert_eq!(restored.summary, tiers.summary);
assert_eq!(restored.tags.len(), 2);
assert_eq!(restored.related_artifacts.len(), 2);
}
#[test]
fn artifact_with_explanation_tiers_round_trip() {
let artifact = Artifact {
resource_uri: "fs://workspace/src/auth/middleware.rs".to_string(),
change_type: ChangeType::Modify,
diff_ref: "changeset:1".to_string(),
tests_run: vec![],
disposition: ArtifactDisposition::Pending,
rationale: Some("Modernize auth".to_string()),
dependencies: vec![],
explanation_tiers: Some(ExplanationTiers {
summary: "Refactored auth to JWT".to_string(),
explanation: "Full JWT integration with validation.".to_string(),
tags: vec!["security".to_string()],
related_artifacts: vec![],
}),
comments: None,
amendment: None,
kind: None,
};
let json = serde_json::to_string(&artifact).unwrap();
let restored: Artifact = serde_json::from_str(&json).unwrap();
assert!(restored.explanation_tiers.is_some());
assert_eq!(
restored.explanation_tiers.as_ref().unwrap().summary,
"Refactored auth to JWT"
);
}
#[test]
fn artifact_without_explanation_tiers_deserializes_correctly() {
let json = r#"{
"resource_uri": "fs://workspace/test.txt",
"change_type": "add",
"diff_ref": "changeset:0"
}"#;
let artifact: Artifact = serde_json::from_str(json).unwrap();
assert!(artifact.explanation_tiers.is_none());
}
#[test]
fn decision_log_entry_with_alternatives_considered() {
let entry = DecisionLogEntry {
decision: "Migrated to JWT auth".to_string(),
rationale: "Session tokens don't scale".to_string(),
alternatives: vec![],
alternatives_considered: vec![
AlternativeConsidered {
description: "Sticky sessions".to_string(),
rejected_reason: "Couples auth to infrastructure".to_string(),
},
AlternativeConsidered {
description: "Redis session store".to_string(),
rejected_reason: "Adds operational dependency".to_string(),
},
],
confidence: None,
context: None,
};
let json = serde_json::to_string(&entry).unwrap();
let restored: DecisionLogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(restored.alternatives_considered.len(), 2);
assert_eq!(
restored.alternatives_considered[0].description,
"Sticky sessions"
);
assert_eq!(
restored.alternatives_considered[1].rejected_reason,
"Adds operational dependency"
);
}
#[test]
fn decision_log_entry_backward_compatible() {
let json = r#"{
"decision": "Used JWT",
"rationale": "Scalability"
}"#;
let entry: DecisionLogEntry = serde_json::from_str(json).unwrap();
assert!(entry.alternatives.is_empty());
assert!(entry.alternatives_considered.is_empty());
}
#[test]
fn policy_decision_record_with_trace_fields() {
let record = PolicyDecisionRecord {
rule_id: "default-deny".to_string(),
effect: "allow".to_string(),
notes: Some("Grant matched".to_string()),
grants_checked: vec!["fs.read on workspace/**".to_string()],
matching_grant: Some("fs.read on workspace/**".to_string()),
evaluation_steps: vec![
"path_traversal: passed".to_string(),
"grant_match: allowed".to_string(),
],
};
let json = serde_json::to_string(&record).unwrap();
let restored: PolicyDecisionRecord = serde_json::from_str(&json).unwrap();
assert_eq!(restored.grants_checked.len(), 1);
assert!(restored.matching_grant.is_some());
assert_eq!(restored.evaluation_steps.len(), 2);
}
#[test]
fn policy_decision_record_backward_compatible() {
let json = r#"{
"rule_id": "test",
"effect": "deny",
"notes": "No grant"
}"#;
let record: PolicyDecisionRecord = serde_json::from_str(json).unwrap();
assert!(record.grants_checked.is_empty());
assert!(record.matching_grant.is_none());
assert!(record.evaluation_steps.is_empty());
}
#[test]
fn amendment_record_serialization() {
let record = AmendmentRecord {
amended_by: "human".to_string(),
amended_at: Utc::now(),
amendment_type: AmendmentType::FileReplaced,
reason: Some("Fixed typo in struct name".to_string()),
};
let json = serde_json::to_string(&record).unwrap();
assert!(json.contains("\"file_replaced\""));
assert!(json.contains("\"human\""));
let restored: AmendmentRecord = serde_json::from_str(&json).unwrap();
assert_eq!(restored.amendment_type, AmendmentType::FileReplaced);
assert_eq!(
restored.reason,
Some("Fixed typo in struct name".to_string())
);
}
#[test]
fn amendment_type_all_variants() {
assert_eq!(
serde_json::to_string(&AmendmentType::FileReplaced).unwrap(),
"\"file_replaced\""
);
assert_eq!(
serde_json::to_string(&AmendmentType::PatchApplied).unwrap(),
"\"patch_applied\""
);
assert_eq!(
serde_json::to_string(&AmendmentType::Dropped).unwrap(),
"\"dropped\""
);
}
#[test]
fn artifact_with_amendment_round_trip() {
let artifact = Artifact {
resource_uri: "fs://workspace/src/lib.rs".to_string(),
change_type: ChangeType::Modify,
diff_ref: "changeset:0".to_string(),
tests_run: vec![],
disposition: ArtifactDisposition::Discuss,
rationale: Some("Needs dedup".to_string()),
dependencies: vec![],
explanation_tiers: None,
comments: None,
amendment: Some(AmendmentRecord {
amended_by: "human".to_string(),
amended_at: Utc::now(),
amendment_type: AmendmentType::FileReplaced,
reason: Some("Deduplicated struct".to_string()),
}),
kind: None,
};
let json = serde_json::to_string(&artifact).unwrap();
let restored: Artifact = serde_json::from_str(&json).unwrap();
assert!(restored.amendment.is_some());
let amend = restored.amendment.unwrap();
assert_eq!(amend.amended_by, "human");
assert_eq!(amend.amendment_type, AmendmentType::FileReplaced);
}
#[test]
fn artifact_without_amendment_backward_compatible() {
let json = r#"{
"resource_uri": "fs://workspace/test.txt",
"change_type": "add",
"diff_ref": "changeset:0"
}"#;
let artifact: Artifact = serde_json::from_str(json).unwrap();
assert!(artifact.amendment.is_none());
}
#[test]
fn design_alternative_serialization() {
let alt = DesignAlternative {
option: "Use HashMap for O(1) lookup".to_string(),
rationale: "Best performance for frequent reads".to_string(),
chosen: true,
};
let json = serde_json::to_string(&alt).unwrap();
assert!(json.contains("\"option\""));
assert!(json.contains("\"chosen\":true"));
let restored: DesignAlternative = serde_json::from_str(&json).unwrap();
assert_eq!(restored, alt);
}
#[test]
fn summary_with_alternatives_round_trip() {
let summary = Summary {
what_changed: "Refactored lookup".to_string(),
why: "Performance".to_string(),
impact: "None".to_string(),
rollback_plan: "Revert".to_string(),
open_questions: vec![],
alternatives_considered: vec![
DesignAlternative {
option: "HashMap".to_string(),
rationale: "O(1) lookup".to_string(),
chosen: true,
},
DesignAlternative {
option: "BTreeMap".to_string(),
rationale: "Ordered but O(log n)".to_string(),
chosen: false,
},
],
};
let json = serde_json::to_string(&summary).unwrap();
let restored: Summary = serde_json::from_str(&json).unwrap();
assert_eq!(restored.alternatives_considered.len(), 2);
assert!(restored.alternatives_considered[0].chosen);
assert!(!restored.alternatives_considered[1].chosen);
}
#[test]
fn summary_without_alternatives_backward_compatible() {
let json = r#"{
"what_changed": "test",
"why": "test",
"impact": "none",
"rollback_plan": "revert"
}"#;
let summary: Summary = serde_json::from_str(json).unwrap();
assert!(summary.alternatives_considered.is_empty());
}
#[test]
fn vcs_tracking_info_serialization_round_trip() {
let vcs = VcsTrackingInfo {
branch: "ta/fix-auth".to_string(),
review_url: Some("https://github.com/org/repo/pull/42".to_string()),
review_id: Some("42".to_string()),
review_state: Some("open".to_string()),
commit_sha: Some("abc1234".to_string()),
last_checked: Utc::now(),
};
let json = serde_json::to_string(&vcs).unwrap();
assert!(json.contains("\"branch\""));
assert!(json.contains("\"review_url\""));
let restored: VcsTrackingInfo = serde_json::from_str(&json).unwrap();
assert_eq!(restored.branch, "ta/fix-auth");
assert_eq!(restored.review_id, Some("42".to_string()));
}
#[test]
fn draft_package_tag_backward_compat() {
let pkg = test_package();
assert!(pkg.tag.is_none());
assert!(pkg.vcs_status.is_none());
let json = serde_json::to_string(&pkg).unwrap();
assert!(!json.contains("\"vcs_status\""));
let restored: DraftPackage = serde_json::from_str(&json).unwrap();
assert!(restored.tag.is_none());
assert!(restored.vcs_status.is_none());
}
#[test]
fn draft_package_with_tag_and_vcs() {
let mut pkg = test_package();
pkg.tag = Some("fix-auth-01".to_string());
pkg.vcs_status = Some(VcsTrackingInfo {
branch: "ta/fix-auth".to_string(),
review_url: None,
review_id: None,
review_state: None,
commit_sha: Some("def5678".to_string()),
last_checked: Utc::now(),
});
let json = serde_json::to_string(&pkg).unwrap();
assert!(json.contains("\"tag\""));
assert!(json.contains("fix-auth-01"));
assert!(json.contains("\"vcs_status\""));
let restored: DraftPackage = serde_json::from_str(&json).unwrap();
assert_eq!(restored.tag, Some("fix-auth-01".to_string()));
assert!(restored.vcs_status.is_some());
}
#[test]
fn agent_decision_log_round_trip() {
let mut pkg = test_package();
pkg.agent_decision_log = vec![DecisionLogEntry {
decision: "Used Ed25519 instead of RSA".to_string(),
rationale: "Ed25519 is faster, smaller keys, already in Cargo.lock".to_string(),
alternatives: vec!["RSA-2048".to_string(), "ECDSA P-256".to_string()],
alternatives_considered: vec![],
confidence: Some(0.9),
context: None,
}];
let json = serde_json::to_string(&pkg).unwrap();
assert!(json.contains("agent_decision_log"));
assert!(json.contains("Ed25519"));
assert!(json.contains("0.9"));
let restored: DraftPackage = serde_json::from_str(&json).unwrap();
assert_eq!(restored.agent_decision_log.len(), 1);
assert_eq!(
restored.agent_decision_log[0].decision,
"Used Ed25519 instead of RSA"
);
assert_eq!(restored.agent_decision_log[0].confidence, Some(0.9));
assert_eq!(restored.agent_decision_log[0].alternatives.len(), 2);
}
#[test]
fn agent_decision_log_backward_compat() {
let pkg = test_package();
let json = serde_json::to_string(&pkg).unwrap();
assert!(!json.contains("agent_decision_log"));
let restored: DraftPackage = serde_json::from_str(&json).unwrap();
assert!(restored.agent_decision_log.is_empty());
}
#[test]
fn decision_log_confidence_optional() {
let entry_json = r#"{"decision":"test","rationale":"reason","alternatives":[]}"#;
let entry: DecisionLogEntry = serde_json::from_str(entry_json).unwrap();
assert_eq!(entry.decision, "test");
assert!(entry.confidence.is_none());
}
#[test]
fn decision_log_entry_with_context() {
let entry = DecisionLogEntry {
decision: "Use Ollama for local inference".to_string(),
rationale: "Privacy and offline requirements".to_string(),
alternatives: vec![],
alternatives_considered: vec![],
confidence: Some(0.8),
context: Some("Ollama thinking-mode config".to_string()),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("context"));
assert!(json.contains("Ollama thinking-mode config"));
let restored: DecisionLogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(
restored.context.as_deref(),
Some("Ollama thinking-mode config")
);
assert_eq!(restored.decision, "Use Ollama for local inference");
}
#[test]
fn decision_log_entry_context_backward_compat() {
let json = r#"{"decision":"Used JWT","rationale":"Scalability"}"#;
let entry: DecisionLogEntry = serde_json::from_str(json).unwrap();
assert!(entry.context.is_none());
}
fn make_artifact(uri: &str) -> Artifact {
Artifact {
resource_uri: uri.to_string(),
change_type: ChangeType::Add,
diff_ref: "changeset:0".to_string(),
tests_run: vec![],
disposition: ArtifactDisposition::Pending,
rationale: None,
dependencies: vec![],
explanation_tiers: None,
comments: None,
amendment: None,
kind: None,
}
}
#[test]
fn missing_decisions_fires_on_code_changes() {
let mut pkg = test_package();
pkg.changes
.artifacts
.push(make_artifact("fs://workspace/src/main.rs"));
let warn = check_missing_decisions(&pkg);
assert!(warn.is_some());
assert!(warn.unwrap().contains("decision log"));
}
#[test]
fn missing_decisions_suppressed_when_decisions_present() {
let mut pkg = test_package();
pkg.changes
.artifacts
.push(make_artifact("fs://workspace/src/main.rs"));
pkg.agent_decision_log.push(DecisionLogEntry {
decision: "Used trait objects for extensibility".to_string(),
rationale: "Allows plugin authors to add new adapters".to_string(),
alternatives: vec!["enum dispatch".to_string()],
alternatives_considered: vec![],
confidence: Some(0.9),
context: None,
});
let warn = check_missing_decisions(&pkg);
assert!(warn.is_none());
}
#[test]
fn missing_decisions_suppressed_for_trivial_changes() {
let mut pkg = test_package();
pkg.changes
.artifacts
.push(make_artifact("fs://workspace/Cargo.toml"));
pkg.changes
.artifacts
.push(make_artifact("fs://workspace/README.md"));
let warn = check_missing_decisions(&pkg);
assert!(warn.is_none());
}
#[test]
fn missing_decisions_fires_for_typescript_and_python() {
let mut pkg = test_package();
pkg.changes
.artifacts
.push(make_artifact("fs://workspace/src/app.ts"));
let warn = check_missing_decisions(&pkg);
assert!(warn.is_some());
let mut pkg2 = test_package();
pkg2.changes
.artifacts
.push(make_artifact("fs://workspace/scripts/process.py"));
let warn2 = check_missing_decisions(&pkg2);
assert!(warn2.is_some());
}
#[test]
fn missing_decisions_suppressed_when_no_artifacts() {
let pkg = test_package();
let warn = check_missing_decisions(&pkg);
assert!(warn.is_none());
}
}