1use std::fmt;
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19use crate::artifact_kind::ArtifactKind;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Goal {
26 pub goal_id: String,
27 pub title: String,
28 pub objective: String,
29 pub success_criteria: Vec<String>,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub constraints: Vec<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub parent_goal_title: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Iteration {
44 pub iteration_id: String,
45 pub sequence: u32,
46 pub workspace_ref: WorkspaceRef,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct WorkspaceRef {
52 #[serde(rename = "type")]
53 pub ref_type: String,
54 #[serde(rename = "ref")]
55 pub ref_name: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub base_ref: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct AgentIdentity {
65 pub agent_id: String,
66 pub agent_type: String,
67 pub constitution_id: String,
68 pub capability_manifest_hash: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub orchestrator_run_id: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct Summary {
78 pub what_changed: String,
79 pub why: String,
80 pub impact: String,
81 pub rollback_plan: String,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub open_questions: Vec<String>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub alternatives_considered: Vec<DesignAlternative>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct DesignAlternative {
96 pub option: String,
98 pub rationale: String,
100 #[serde(default)]
102 pub chosen: bool,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Changes {
111 pub artifacts: Vec<Artifact>,
112 pub patch_sets: Vec<PatchSet>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub pending_actions: Vec<PendingAction>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct PendingAction {
127 pub action_id: Uuid,
129 pub tool_name: String,
131 pub parameters: serde_json::Value,
133 pub kind: ActionKind,
135 pub intercepted_at: DateTime<Utc>,
137 pub description: String,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub target_uri: Option<String>,
142 #[serde(default)]
144 pub disposition: ArtifactDisposition,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "snake_case")]
150pub enum ActionKind {
151 ReadOnly,
153 StateChanging,
155 Unclassified,
157}
158
159impl fmt::Display for ActionKind {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 match self {
162 ActionKind::ReadOnly => write!(f, "read-only"),
163 ActionKind::StateChanging => write!(f, "state-changing"),
164 ActionKind::Unclassified => write!(f, "unclassified"),
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174pub struct ExplanationTiers {
175 pub summary: String,
177 pub explanation: String,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub tags: Vec<String>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub related_artifacts: Vec<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct Artifact {
190 pub resource_uri: String,
191 pub change_type: ChangeType,
192 pub diff_ref: String,
193 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 pub tests_run: Vec<String>,
195 #[serde(default)]
197 pub disposition: ArtifactDisposition,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub rationale: Option<String>,
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub dependencies: Vec<ChangeDependency>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub explanation_tiers: Option<ExplanationTiers>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub comments: Option<crate::review_session::CommentThread>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub amendment: Option<AmendmentRecord>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub kind: Option<ArtifactKind>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct AmendmentRecord {
226 pub amended_by: String,
228 pub amended_at: DateTime<Utc>,
230 pub amendment_type: AmendmentType,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub reason: Option<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239#[serde(rename_all = "snake_case")]
240pub enum AmendmentType {
241 FileReplaced,
243 PatchApplied,
245 Dropped,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
254#[serde(rename_all = "snake_case")]
255pub enum ArtifactDisposition {
256 #[default]
258 Pending,
259 Approved,
261 Rejected,
263 Discuss,
265}
266
267impl fmt::Display for ArtifactDisposition {
268 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269 match self {
270 ArtifactDisposition::Pending => write!(f, "pending"),
271 ArtifactDisposition::Approved => write!(f, "approved"),
272 ArtifactDisposition::Rejected => write!(f, "rejected"),
273 ArtifactDisposition::Discuss => write!(f, "discuss"),
274 }
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub struct ChangeDependency {
284 pub target_uri: String,
286 pub kind: DependencyKind,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
292#[serde(rename_all = "snake_case")]
293pub enum DependencyKind {
294 DependsOn,
296 DependedBy,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
302#[serde(rename_all = "snake_case")]
303pub enum ChangeType {
304 Add,
305 Modify,
306 Delete,
307 Rename,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct PatchSet {
313 pub patch_set_id: String,
314 pub target_uri: String,
315 pub action: PatchAction,
316 pub preview_ref: String,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub commit_intent: Option<String>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
323#[serde(rename_all = "snake_case")]
324pub enum PatchAction {
325 WritePatch,
326 CreateDraft,
327 LabelChange,
328 PermissionChange,
329 DbPatch,
330 SchedulePost,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct Risk {
338 pub risk_score: u32,
339 pub findings: Vec<RiskFinding>,
340 pub policy_decisions: Vec<PolicyDecisionRecord>,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct RiskFinding {
346 pub category: RiskCategory,
347 pub severity: Severity,
348 pub description: String,
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub evidence_refs: Vec<String>,
351 #[serde(skip_serializing_if = "Option::is_none")]
352 pub mitigation: Option<String>,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
356#[serde(rename_all = "snake_case")]
357pub enum RiskCategory {
358 Pii,
359 Secrets,
360 Exfiltration,
361 ExternalComm,
362 PromptInjection,
363 PolicyViolation,
364 Unknown,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
368#[serde(rename_all = "snake_case")]
369pub enum Severity {
370 Low,
371 Medium,
372 High,
373 Critical,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct PolicyDecisionRecord {
379 pub rule_id: String,
380 pub effect: String,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub notes: Option<String>,
383 #[serde(default, skip_serializing_if = "Vec::is_empty")]
385 pub grants_checked: Vec<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub matching_grant: Option<String>,
389 #[serde(default, skip_serializing_if = "Vec::is_empty")]
391 pub evaluation_steps: Vec<String>,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct Provenance {
399 pub inputs: Vec<ProvenanceInput>,
400 pub tool_trace_hash: String,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct ProvenanceInput {
405 pub source_type: String,
406 #[serde(rename = "ref")]
407 pub ref_uri: String,
408 pub trust_level: TrustLevel,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub notes: Option<String>,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
414#[serde(rename_all = "snake_case")]
415pub enum TrustLevel {
416 Trusted,
417 Untrusted,
418 Quarantined,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct ReviewRequests {
426 pub requested_actions: Vec<RequestedAction>,
427 pub reviewers: Vec<String>,
428 #[serde(default = "default_required_approvals")]
429 pub required_approvals: u32,
430 #[serde(skip_serializing_if = "Option::is_none")]
431 pub notes_to_reviewer: Option<String>,
432}
433
434fn default_required_approvals() -> u32 {
435 1
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct RequestedAction {
440 pub action: String,
441 pub targets: Vec<String>,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct Signatures {
449 pub package_hash: String,
450 pub agent_signature: String,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub gateway_attestation: Option<String>,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct ApprovalRecord {
464 pub reviewer: String,
466 pub approved_at: DateTime<Utc>,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct DraftPackage {
478 pub package_version: String,
479 pub package_id: Uuid,
480 pub created_at: DateTime<Utc>,
481 pub goal: Goal,
482 pub iteration: Iteration,
483 pub agent_identity: AgentIdentity,
484 pub summary: Summary,
485 pub plan: Plan,
486 pub changes: Changes,
487 pub risk: Risk,
488 pub provenance: Provenance,
489 pub review_requests: ReviewRequests,
490 pub signatures: Signatures,
491
492 #[serde(default)]
494 pub status: DraftStatus,
495
496 #[serde(default, skip_serializing_if = "Vec::is_empty")]
499 pub verification_warnings: Vec<VerificationWarning>,
500
501 #[serde(default, skip_serializing_if = "Vec::is_empty")]
505 pub validation_log: Vec<ValidationEntry>,
506
507 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub display_id: Option<String>,
512
513 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub tag: Option<String>,
517
518 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub vcs_status: Option<VcsTrackingInfo>,
522
523 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub parent_draft_id: Option<Uuid>,
528
529 #[serde(default, skip_serializing_if = "Vec::is_empty")]
535 pub pending_approvals: Vec<ApprovalRecord>,
536
537 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub supervisor_review: Option<crate::supervisor_review::SupervisorReview>,
541
542 #[serde(default, skip_serializing_if = "Vec::is_empty")]
548 pub ignored_artifacts: Vec<IgnoredArtifact>,
549
550 #[serde(default, skip_serializing_if = "Vec::is_empty")]
562 pub baseline_artifacts: Vec<String>,
563
564 #[serde(default, skip_serializing_if = "Vec::is_empty")]
571 pub agent_decision_log: Vec<DecisionLogEntry>,
572
573 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub goal_shortref: Option<String>,
581
582 #[serde(default)]
588 pub draft_seq: u32,
589
590 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub plan_phase: Option<String>,
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct VcsTrackingInfo {
602 pub branch: String,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub review_url: Option<String>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
609 pub review_id: Option<String>,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub review_state: Option<String>,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub commit_sha: Option<String>,
616 pub last_checked: DateTime<Utc>,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct VerificationWarning {
623 pub command: String,
625 pub exit_code: Option<i32>,
627 pub output: String,
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
635pub struct IgnoredArtifact {
636 pub path: String,
638 pub known_safe: bool,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
644pub struct ValidationEntry {
645 pub command: String,
647 pub exit_code: i32,
649 pub duration_secs: u64,
651 pub stdout_tail: String,
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize)]
657pub struct Plan {
658 pub completed_steps: Vec<String>,
659 pub next_steps: Vec<String>,
660 #[serde(default, skip_serializing_if = "Vec::is_empty")]
661 pub decision_log: Vec<DecisionLogEntry>,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize)]
665pub struct DecisionLogEntry {
666 pub decision: String,
667 pub rationale: String,
668 #[serde(default, skip_serializing_if = "Vec::is_empty")]
669 pub alternatives: Vec<String>,
670 #[serde(default, skip_serializing_if = "Vec::is_empty")]
672 pub alternatives_considered: Vec<AlternativeConsidered>,
673 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub confidence: Option<f32>,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub context: Option<String>,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
684pub struct AlternativeConsidered {
685 pub description: String,
686 pub rejected_reason: String,
687}
688
689#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
691#[serde(tag = "via", rename_all = "snake_case")]
692pub enum ApplyProvenance {
693 #[default]
695 Manual,
696 BackgroundTask { task_id: String },
698 AutoMerge,
700}
701
702impl std::fmt::Display for ApplyProvenance {
703 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
704 match self {
705 ApplyProvenance::Manual => write!(f, "manual"),
706 ApplyProvenance::BackgroundTask { .. } => write!(f, "background"),
707 ApplyProvenance::AutoMerge => write!(f, "auto-merge"),
708 }
709 }
710}
711
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
714#[serde(tag = "status", rename_all = "snake_case")]
715pub enum DraftStatus {
716 #[default]
717 Draft,
718 PendingReview,
719 Approved {
720 approved_by: String,
721 approved_at: DateTime<Utc>,
722 },
723 Denied {
724 reason: String,
725 denied_by: String,
726 },
727 Applied {
728 applied_at: DateTime<Utc>,
729 #[serde(default)]
732 applied_via: ApplyProvenance,
733 },
734 Superseded {
736 superseded_by: Uuid,
737 },
738 Closed {
740 closed_at: DateTime<Utc>,
741 reason: Option<String>,
742 closed_by: String,
743 },
744}
745
746impl std::fmt::Display for DraftStatus {
747 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748 match self {
749 DraftStatus::Draft => write!(f, "draft"),
750 DraftStatus::PendingReview => write!(f, "pending_review"),
751 DraftStatus::Approved { .. } => write!(f, "approved"),
752 DraftStatus::Denied { .. } => write!(f, "denied"),
753 DraftStatus::Applied { .. } => write!(f, "applied"),
754 DraftStatus::Superseded { .. } => write!(f, "superseded"),
755 DraftStatus::Closed { .. } => write!(f, "closed"),
756 }
757 }
758}
759
760#[cfg(test)]
766pub fn make_test_pkg(goal_shortref: &str, draft_seq: u32) -> DraftPackage {
767 DraftPackage {
768 package_version: "1.0.0".to_string(),
769 package_id: Uuid::new_v4(),
770 created_at: chrono::Utc::now(),
771 goal: Goal {
772 goal_id: format!("{}-0000-0000-0000-000000000000", goal_shortref),
773 title: format!("Test goal {}", goal_shortref),
774 objective: "test".to_string(),
775 success_criteria: vec![],
776 constraints: vec![],
777 parent_goal_title: None,
778 },
779 iteration: Iteration {
780 iteration_id: "iter-1".to_string(),
781 sequence: 1,
782 workspace_ref: WorkspaceRef {
783 ref_type: "staging_dir".to_string(),
784 ref_name: "staging/test".to_string(),
785 base_ref: None,
786 },
787 },
788 agent_identity: AgentIdentity {
789 agent_id: "test-agent".to_string(),
790 agent_type: "test".to_string(),
791 constitution_id: "default".to_string(),
792 capability_manifest_hash: "abc".to_string(),
793 orchestrator_run_id: None,
794 },
795 summary: Summary {
796 what_changed: "test".to_string(),
797 why: "test".to_string(),
798 impact: "none".to_string(),
799 rollback_plan: "none".to_string(),
800 open_questions: vec![],
801 alternatives_considered: vec![],
802 },
803 plan: Plan {
804 completed_steps: vec![],
805 next_steps: vec![],
806 decision_log: vec![],
807 },
808 changes: Changes {
809 artifacts: vec![],
810 patch_sets: vec![],
811 pending_actions: vec![],
812 },
813 risk: Risk {
814 risk_score: 0,
815 findings: vec![],
816 policy_decisions: vec![],
817 },
818 provenance: Provenance {
819 inputs: vec![],
820 tool_trace_hash: "test".to_string(),
821 },
822 review_requests: ReviewRequests {
823 requested_actions: vec![],
824 reviewers: vec![],
825 required_approvals: 1,
826 notes_to_reviewer: None,
827 },
828 signatures: Signatures {
829 package_hash: "test".to_string(),
830 agent_signature: "test".to_string(),
831 gateway_attestation: None,
832 },
833 status: DraftStatus::PendingReview,
834 verification_warnings: vec![],
835 validation_log: vec![],
836 display_id: None,
837 tag: None,
838 vcs_status: None,
839 parent_draft_id: None,
840 pending_approvals: vec![],
841 supervisor_review: None,
842 ignored_artifacts: vec![],
843 baseline_artifacts: vec![],
844 agent_decision_log: vec![],
845 goal_shortref: Some(goal_shortref.to_string()),
846 draft_seq,
847 plan_phase: None,
848 }
849}
850
851pub fn check_missing_decisions(pkg: &DraftPackage) -> Option<String> {
863 if !pkg.agent_decision_log.is_empty() {
865 return None;
866 }
867
868 let substantive_extensions = [
869 "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "cpp", "c", "h",
870 ];
871
872 let has_substantive_code = pkg.changes.artifacts.iter().any(|a| {
873 let uri = &a.resource_uri;
874 if let Some(path_part) = uri.strip_prefix("fs://workspace/") {
876 let ext = std::path::Path::new(path_part)
877 .extension()
878 .and_then(|e| e.to_str())
879 .unwrap_or("");
880 substantive_extensions.contains(&ext)
881 } else {
882 false
883 }
884 });
885
886 if has_substantive_code {
887 Some(
888 "No agent decision log entries found for a goal with significant code changes. \
889 Consider `ta run --follow-up` to capture design rationale before approving."
890 .to_string(),
891 )
892 } else {
893 None
894 }
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900
901 fn test_package() -> DraftPackage {
903 DraftPackage {
904 package_version: "1.0.0".to_string(),
905 package_id: Uuid::new_v4(),
906 created_at: Utc::now(),
907 goal: Goal {
908 goal_id: "goal-1".to_string(),
909 title: "Test Goal".to_string(),
910 objective: "Test the system".to_string(),
911 success_criteria: vec!["tests pass".to_string()],
912 constraints: vec![],
913 parent_goal_title: None,
914 },
915 iteration: Iteration {
916 iteration_id: "iter-1".to_string(),
917 sequence: 1,
918 workspace_ref: WorkspaceRef {
919 ref_type: "staging_dir".to_string(),
920 ref_name: "staging/goal-1/1".to_string(),
921 base_ref: None,
922 },
923 },
924 agent_identity: AgentIdentity {
925 agent_id: "agent-1".to_string(),
926 agent_type: "research".to_string(),
927 constitution_id: "default".to_string(),
928 capability_manifest_hash: "abc123".to_string(),
929 orchestrator_run_id: None,
930 },
931 summary: Summary {
932 what_changed: "Added test file".to_string(),
933 why: "To verify the system works".to_string(),
934 impact: "No production impact".to_string(),
935 rollback_plan: "Delete the file".to_string(),
936 open_questions: vec![],
937 alternatives_considered: vec![],
938 },
939 plan: Plan {
940 completed_steps: vec!["Created file".to_string()],
941 next_steps: vec![],
942 decision_log: vec![],
943 },
944 changes: Changes {
945 artifacts: vec![Artifact {
946 resource_uri: "fs://workspace/test.txt".to_string(),
947 change_type: ChangeType::Add,
948 diff_ref: "diff-001".to_string(),
949 tests_run: vec![],
950 disposition: Default::default(),
951 rationale: None,
952 dependencies: vec![],
953 explanation_tiers: None,
954 comments: None,
955 amendment: None,
956 kind: None,
957 }],
958 patch_sets: vec![],
959 pending_actions: vec![],
960 },
961 risk: Risk {
962 risk_score: 10,
963 findings: vec![],
964 policy_decisions: vec![],
965 },
966 provenance: Provenance {
967 inputs: vec![],
968 tool_trace_hash: "trace-hash-123".to_string(),
969 },
970 review_requests: ReviewRequests {
971 requested_actions: vec![RequestedAction {
972 action: "merge".to_string(),
973 targets: vec!["fs://workspace/test.txt".to_string()],
974 }],
975 reviewers: vec!["human-reviewer".to_string()],
976 required_approvals: 1,
977 notes_to_reviewer: None,
978 },
979 signatures: Signatures {
980 package_hash: "pkg-hash-456".to_string(),
981 agent_signature: "sig-789".to_string(),
982 gateway_attestation: None,
983 },
984 status: DraftStatus::Draft,
985 verification_warnings: vec![],
986 validation_log: vec![],
987 display_id: None,
988 tag: None,
989 vcs_status: None,
990 parent_draft_id: None,
991 pending_approvals: vec![],
992 supervisor_review: None,
993 ignored_artifacts: vec![],
994 baseline_artifacts: vec![],
995 agent_decision_log: vec![],
996 goal_shortref: None,
997 draft_seq: 0,
998 plan_phase: None,
999 }
1000 }
1001
1002 #[test]
1003 fn draft_package_serialization_round_trip() {
1004 let pkg = test_package();
1005 let json = serde_json::to_string_pretty(&pkg).unwrap();
1006 let restored: DraftPackage = serde_json::from_str(&json).unwrap();
1007
1008 assert_eq!(pkg.package_id, restored.package_id);
1009 assert_eq!(pkg.package_version, restored.package_version);
1010 assert_eq!(pkg.goal.goal_id, restored.goal.goal_id);
1011 assert_eq!(
1012 pkg.changes.artifacts.len(),
1013 restored.changes.artifacts.len()
1014 );
1015 }
1016
1017 #[test]
1018 fn draft_status_transitions() {
1019 let status = DraftStatus::PendingReview;
1021 assert_eq!(status.to_string(), "pending_review");
1022
1023 let status = DraftStatus::Approved {
1025 approved_by: "reviewer".to_string(),
1026 approved_at: Utc::now(),
1027 };
1028 assert_eq!(status.to_string(), "approved");
1029
1030 let status = DraftStatus::Denied {
1032 reason: "needs changes".to_string(),
1033 denied_by: "reviewer".to_string(),
1034 };
1035 assert_eq!(status.to_string(), "denied");
1036
1037 let status = DraftStatus::Applied {
1039 applied_at: Utc::now(),
1040 applied_via: ApplyProvenance::Manual,
1041 };
1042 assert_eq!(status.to_string(), "applied");
1043 }
1044
1045 #[test]
1046 fn draft_status_default_is_draft() {
1047 let status = DraftStatus::default();
1048 assert_eq!(status, DraftStatus::Draft);
1049 }
1050
1051 #[test]
1052 fn draft_package_json_contains_required_fields() {
1053 let pkg = test_package();
1055 let json = serde_json::to_string(&pkg).unwrap();
1056
1057 assert!(json.contains("\"package_version\""));
1059 assert!(json.contains("\"package_id\""));
1060 assert!(json.contains("\"created_at\""));
1061 assert!(json.contains("\"goal\""));
1062 assert!(json.contains("\"iteration\""));
1063 assert!(json.contains("\"agent_identity\""));
1064 assert!(json.contains("\"summary\""));
1065 assert!(json.contains("\"changes\""));
1066 assert!(json.contains("\"risk\""));
1067 assert!(json.contains("\"provenance\""));
1068 assert!(json.contains("\"review_requests\""));
1069 assert!(json.contains("\"signatures\""));
1070 }
1071
1072 #[test]
1073 fn risk_finding_serialization() {
1074 let finding = RiskFinding {
1075 category: RiskCategory::Secrets,
1076 severity: Severity::High,
1077 description: "API key detected in file".to_string(),
1078 evidence_refs: vec!["line 42".to_string()],
1079 mitigation: Some("Remove the key".to_string()),
1080 };
1081 let json = serde_json::to_string(&finding).unwrap();
1082 assert!(json.contains("\"secrets\""));
1083 assert!(json.contains("\"high\""));
1084 }
1085
1086 #[test]
1087 fn change_type_serialization() {
1088 assert_eq!(serde_json::to_string(&ChangeType::Add).unwrap(), "\"add\"");
1089 assert_eq!(
1090 serde_json::to_string(&ChangeType::Modify).unwrap(),
1091 "\"modify\""
1092 );
1093 }
1094
1095 #[test]
1096 fn artifact_disposition_default_is_pending() {
1097 let d = ArtifactDisposition::default();
1098 assert_eq!(d, ArtifactDisposition::Pending);
1099 assert_eq!(d.to_string(), "pending");
1100 }
1101
1102 #[test]
1103 fn artifact_disposition_serialization() {
1104 assert_eq!(
1105 serde_json::to_string(&ArtifactDisposition::Approved).unwrap(),
1106 "\"approved\""
1107 );
1108 assert_eq!(
1109 serde_json::to_string(&ArtifactDisposition::Rejected).unwrap(),
1110 "\"rejected\""
1111 );
1112 assert_eq!(
1113 serde_json::to_string(&ArtifactDisposition::Discuss).unwrap(),
1114 "\"discuss\""
1115 );
1116 }
1117
1118 #[test]
1119 fn artifact_with_disposition_round_trip() {
1120 let artifact = Artifact {
1121 resource_uri: "fs://workspace/src/main.rs".to_string(),
1122 change_type: ChangeType::Modify,
1123 diff_ref: "changeset:0".to_string(),
1124 tests_run: vec![],
1125 disposition: ArtifactDisposition::Approved,
1126 rationale: Some("Fixed the bug".to_string()),
1127 dependencies: vec![ChangeDependency {
1128 target_uri: "fs://workspace/src/lib.rs".to_string(),
1129 kind: DependencyKind::DependsOn,
1130 }],
1131 explanation_tiers: None,
1132 comments: None,
1133 amendment: None,
1134 kind: None,
1135 };
1136 let json = serde_json::to_string(&artifact).unwrap();
1137 let restored: Artifact = serde_json::from_str(&json).unwrap();
1138 assert_eq!(restored.disposition, ArtifactDisposition::Approved);
1139 assert_eq!(restored.rationale, Some("Fixed the bug".to_string()));
1140 assert_eq!(restored.dependencies.len(), 1);
1141 assert_eq!(restored.dependencies[0].kind, DependencyKind::DependsOn);
1142 }
1143
1144 #[test]
1145 fn artifact_without_new_fields_deserializes_with_defaults() {
1146 let json = r#"{
1148 "resource_uri": "fs://workspace/test.txt",
1149 "change_type": "add",
1150 "diff_ref": "changeset:0"
1151 }"#;
1152 let artifact: Artifact = serde_json::from_str(json).unwrap();
1153 assert_eq!(artifact.disposition, ArtifactDisposition::Pending);
1154 assert!(artifact.rationale.is_none());
1155 assert!(artifact.dependencies.is_empty());
1156 }
1157
1158 #[test]
1159 fn dependency_kind_serialization() {
1160 assert_eq!(
1161 serde_json::to_string(&DependencyKind::DependsOn).unwrap(),
1162 "\"depends_on\""
1163 );
1164 assert_eq!(
1165 serde_json::to_string(&DependencyKind::DependedBy).unwrap(),
1166 "\"depended_by\""
1167 );
1168 }
1169
1170 #[test]
1171 fn draft_status_superseded_serialization() {
1172 let superseding_id = Uuid::new_v4();
1173 let status = DraftStatus::Superseded {
1174 superseded_by: superseding_id,
1175 };
1176 assert_eq!(status.to_string(), "superseded");
1177 let json = serde_json::to_string(&status).unwrap();
1178 assert!(json.contains("\"superseded\""));
1179 assert!(json.contains(&superseding_id.to_string()));
1180 let restored: DraftStatus = serde_json::from_str(&json).unwrap();
1181 assert_eq!(restored, status);
1182 }
1183
1184 #[test]
1185 fn draft_status_closed_serialization() {
1186 let status = DraftStatus::Closed {
1187 closed_at: Utc::now(),
1188 reason: Some("Hand-merged upstream".to_string()),
1189 closed_by: "human-reviewer".to_string(),
1190 };
1191 assert_eq!(status.to_string(), "closed");
1192 let json = serde_json::to_string(&status).unwrap();
1193 assert!(json.contains("\"closed\""));
1194 assert!(json.contains("Hand-merged upstream"));
1195 let restored: DraftStatus = serde_json::from_str(&json).unwrap();
1196 assert_eq!(restored, status);
1197 }
1198
1199 #[test]
1200 fn draft_status_closed_without_reason() {
1201 let status = DraftStatus::Closed {
1202 closed_at: Utc::now(),
1203 reason: None,
1204 closed_by: "human-reviewer".to_string(),
1205 };
1206 let json = serde_json::to_string(&status).unwrap();
1207 let restored: DraftStatus = serde_json::from_str(&json).unwrap();
1208 assert_eq!(restored, status);
1209 }
1210
1211 #[test]
1212 fn explanation_tiers_serialization() {
1213 let tiers = ExplanationTiers {
1214 summary: "Refactored auth middleware to use JWT".to_string(),
1215 explanation: "Replaced session-based auth with JWT validation.".to_string(),
1216 tags: vec!["security".to_string(), "breaking-change".to_string()],
1217 related_artifacts: vec![
1218 "fs://workspace/src/auth/config.rs".to_string(),
1219 "fs://workspace/tests/auth_test.rs".to_string(),
1220 ],
1221 };
1222 let json = serde_json::to_string(&tiers).unwrap();
1223 assert!(json.contains("\"summary\""));
1224 assert!(json.contains("\"explanation\""));
1225 assert!(json.contains("\"tags\""));
1226 assert!(json.contains("\"security\""));
1227 let restored: ExplanationTiers = serde_json::from_str(&json).unwrap();
1228 assert_eq!(restored.summary, tiers.summary);
1229 assert_eq!(restored.tags.len(), 2);
1230 assert_eq!(restored.related_artifacts.len(), 2);
1231 }
1232
1233 #[test]
1234 fn artifact_with_explanation_tiers_round_trip() {
1235 let artifact = Artifact {
1236 resource_uri: "fs://workspace/src/auth/middleware.rs".to_string(),
1237 change_type: ChangeType::Modify,
1238 diff_ref: "changeset:1".to_string(),
1239 tests_run: vec![],
1240 disposition: ArtifactDisposition::Pending,
1241 rationale: Some("Modernize auth".to_string()),
1242 dependencies: vec![],
1243 explanation_tiers: Some(ExplanationTiers {
1244 summary: "Refactored auth to JWT".to_string(),
1245 explanation: "Full JWT integration with validation.".to_string(),
1246 tags: vec!["security".to_string()],
1247 related_artifacts: vec![],
1248 }),
1249 comments: None,
1250 amendment: None,
1251 kind: None,
1252 };
1253 let json = serde_json::to_string(&artifact).unwrap();
1254 let restored: Artifact = serde_json::from_str(&json).unwrap();
1255 assert!(restored.explanation_tiers.is_some());
1256 assert_eq!(
1257 restored.explanation_tiers.as_ref().unwrap().summary,
1258 "Refactored auth to JWT"
1259 );
1260 }
1261
1262 #[test]
1263 fn artifact_without_explanation_tiers_deserializes_correctly() {
1264 let json = r#"{
1266 "resource_uri": "fs://workspace/test.txt",
1267 "change_type": "add",
1268 "diff_ref": "changeset:0"
1269 }"#;
1270 let artifact: Artifact = serde_json::from_str(json).unwrap();
1271 assert!(artifact.explanation_tiers.is_none());
1272 }
1273
1274 #[test]
1277 fn decision_log_entry_with_alternatives_considered() {
1278 let entry = DecisionLogEntry {
1279 decision: "Migrated to JWT auth".to_string(),
1280 rationale: "Session tokens don't scale".to_string(),
1281 alternatives: vec![],
1282 alternatives_considered: vec![
1283 AlternativeConsidered {
1284 description: "Sticky sessions".to_string(),
1285 rejected_reason: "Couples auth to infrastructure".to_string(),
1286 },
1287 AlternativeConsidered {
1288 description: "Redis session store".to_string(),
1289 rejected_reason: "Adds operational dependency".to_string(),
1290 },
1291 ],
1292 confidence: None,
1293 context: None,
1294 };
1295
1296 let json = serde_json::to_string(&entry).unwrap();
1297 let restored: DecisionLogEntry = serde_json::from_str(&json).unwrap();
1298
1299 assert_eq!(restored.alternatives_considered.len(), 2);
1300 assert_eq!(
1301 restored.alternatives_considered[0].description,
1302 "Sticky sessions"
1303 );
1304 assert_eq!(
1305 restored.alternatives_considered[1].rejected_reason,
1306 "Adds operational dependency"
1307 );
1308 }
1309
1310 #[test]
1311 fn decision_log_entry_backward_compatible() {
1312 let json = r#"{
1314 "decision": "Used JWT",
1315 "rationale": "Scalability"
1316 }"#;
1317 let entry: DecisionLogEntry = serde_json::from_str(json).unwrap();
1318 assert!(entry.alternatives.is_empty());
1319 assert!(entry.alternatives_considered.is_empty());
1320 }
1321
1322 #[test]
1323 fn policy_decision_record_with_trace_fields() {
1324 let record = PolicyDecisionRecord {
1325 rule_id: "default-deny".to_string(),
1326 effect: "allow".to_string(),
1327 notes: Some("Grant matched".to_string()),
1328 grants_checked: vec!["fs.read on workspace/**".to_string()],
1329 matching_grant: Some("fs.read on workspace/**".to_string()),
1330 evaluation_steps: vec![
1331 "path_traversal: passed".to_string(),
1332 "grant_match: allowed".to_string(),
1333 ],
1334 };
1335
1336 let json = serde_json::to_string(&record).unwrap();
1337 let restored: PolicyDecisionRecord = serde_json::from_str(&json).unwrap();
1338
1339 assert_eq!(restored.grants_checked.len(), 1);
1340 assert!(restored.matching_grant.is_some());
1341 assert_eq!(restored.evaluation_steps.len(), 2);
1342 }
1343
1344 #[test]
1345 fn policy_decision_record_backward_compatible() {
1346 let json = r#"{
1348 "rule_id": "test",
1349 "effect": "deny",
1350 "notes": "No grant"
1351 }"#;
1352 let record: PolicyDecisionRecord = serde_json::from_str(json).unwrap();
1353 assert!(record.grants_checked.is_empty());
1354 assert!(record.matching_grant.is_none());
1355 assert!(record.evaluation_steps.is_empty());
1356 }
1357
1358 #[test]
1361 fn amendment_record_serialization() {
1362 let record = AmendmentRecord {
1363 amended_by: "human".to_string(),
1364 amended_at: Utc::now(),
1365 amendment_type: AmendmentType::FileReplaced,
1366 reason: Some("Fixed typo in struct name".to_string()),
1367 };
1368 let json = serde_json::to_string(&record).unwrap();
1369 assert!(json.contains("\"file_replaced\""));
1370 assert!(json.contains("\"human\""));
1371 let restored: AmendmentRecord = serde_json::from_str(&json).unwrap();
1372 assert_eq!(restored.amendment_type, AmendmentType::FileReplaced);
1373 assert_eq!(
1374 restored.reason,
1375 Some("Fixed typo in struct name".to_string())
1376 );
1377 }
1378
1379 #[test]
1380 fn amendment_type_all_variants() {
1381 assert_eq!(
1382 serde_json::to_string(&AmendmentType::FileReplaced).unwrap(),
1383 "\"file_replaced\""
1384 );
1385 assert_eq!(
1386 serde_json::to_string(&AmendmentType::PatchApplied).unwrap(),
1387 "\"patch_applied\""
1388 );
1389 assert_eq!(
1390 serde_json::to_string(&AmendmentType::Dropped).unwrap(),
1391 "\"dropped\""
1392 );
1393 }
1394
1395 #[test]
1396 fn artifact_with_amendment_round_trip() {
1397 let artifact = Artifact {
1398 resource_uri: "fs://workspace/src/lib.rs".to_string(),
1399 change_type: ChangeType::Modify,
1400 diff_ref: "changeset:0".to_string(),
1401 tests_run: vec![],
1402 disposition: ArtifactDisposition::Discuss,
1403 rationale: Some("Needs dedup".to_string()),
1404 dependencies: vec![],
1405 explanation_tiers: None,
1406 comments: None,
1407 amendment: Some(AmendmentRecord {
1408 amended_by: "human".to_string(),
1409 amended_at: Utc::now(),
1410 amendment_type: AmendmentType::FileReplaced,
1411 reason: Some("Deduplicated struct".to_string()),
1412 }),
1413 kind: None,
1414 };
1415 let json = serde_json::to_string(&artifact).unwrap();
1416 let restored: Artifact = serde_json::from_str(&json).unwrap();
1417 assert!(restored.amendment.is_some());
1418 let amend = restored.amendment.unwrap();
1419 assert_eq!(amend.amended_by, "human");
1420 assert_eq!(amend.amendment_type, AmendmentType::FileReplaced);
1421 }
1422
1423 #[test]
1424 fn artifact_without_amendment_backward_compatible() {
1425 let json = r#"{
1427 "resource_uri": "fs://workspace/test.txt",
1428 "change_type": "add",
1429 "diff_ref": "changeset:0"
1430 }"#;
1431 let artifact: Artifact = serde_json::from_str(json).unwrap();
1432 assert!(artifact.amendment.is_none());
1433 }
1434
1435 #[test]
1438 fn design_alternative_serialization() {
1439 let alt = DesignAlternative {
1440 option: "Use HashMap for O(1) lookup".to_string(),
1441 rationale: "Best performance for frequent reads".to_string(),
1442 chosen: true,
1443 };
1444 let json = serde_json::to_string(&alt).unwrap();
1445 assert!(json.contains("\"option\""));
1446 assert!(json.contains("\"chosen\":true"));
1447 let restored: DesignAlternative = serde_json::from_str(&json).unwrap();
1448 assert_eq!(restored, alt);
1449 }
1450
1451 #[test]
1452 fn summary_with_alternatives_round_trip() {
1453 let summary = Summary {
1454 what_changed: "Refactored lookup".to_string(),
1455 why: "Performance".to_string(),
1456 impact: "None".to_string(),
1457 rollback_plan: "Revert".to_string(),
1458 open_questions: vec![],
1459 alternatives_considered: vec![
1460 DesignAlternative {
1461 option: "HashMap".to_string(),
1462 rationale: "O(1) lookup".to_string(),
1463 chosen: true,
1464 },
1465 DesignAlternative {
1466 option: "BTreeMap".to_string(),
1467 rationale: "Ordered but O(log n)".to_string(),
1468 chosen: false,
1469 },
1470 ],
1471 };
1472 let json = serde_json::to_string(&summary).unwrap();
1473 let restored: Summary = serde_json::from_str(&json).unwrap();
1474 assert_eq!(restored.alternatives_considered.len(), 2);
1475 assert!(restored.alternatives_considered[0].chosen);
1476 assert!(!restored.alternatives_considered[1].chosen);
1477 }
1478
1479 #[test]
1480 fn summary_without_alternatives_backward_compatible() {
1481 let json = r#"{
1482 "what_changed": "test",
1483 "why": "test",
1484 "impact": "none",
1485 "rollback_plan": "revert"
1486 }"#;
1487 let summary: Summary = serde_json::from_str(json).unwrap();
1488 assert!(summary.alternatives_considered.is_empty());
1489 }
1490
1491 #[test]
1492 fn vcs_tracking_info_serialization_round_trip() {
1493 let vcs = VcsTrackingInfo {
1494 branch: "ta/fix-auth".to_string(),
1495 review_url: Some("https://github.com/org/repo/pull/42".to_string()),
1496 review_id: Some("42".to_string()),
1497 review_state: Some("open".to_string()),
1498 commit_sha: Some("abc1234".to_string()),
1499 last_checked: Utc::now(),
1500 };
1501 let json = serde_json::to_string(&vcs).unwrap();
1502 assert!(json.contains("\"branch\""));
1503 assert!(json.contains("\"review_url\""));
1504 let restored: VcsTrackingInfo = serde_json::from_str(&json).unwrap();
1505 assert_eq!(restored.branch, "ta/fix-auth");
1506 assert_eq!(restored.review_id, Some("42".to_string()));
1507 }
1508
1509 #[test]
1510 fn draft_package_tag_backward_compat() {
1511 let pkg = test_package();
1513 assert!(pkg.tag.is_none());
1514 assert!(pkg.vcs_status.is_none());
1515 let json = serde_json::to_string(&pkg).unwrap();
1516 assert!(!json.contains("\"vcs_status\""));
1517 let restored: DraftPackage = serde_json::from_str(&json).unwrap();
1518 assert!(restored.tag.is_none());
1519 assert!(restored.vcs_status.is_none());
1520 }
1521
1522 #[test]
1523 fn draft_package_with_tag_and_vcs() {
1524 let mut pkg = test_package();
1525 pkg.tag = Some("fix-auth-01".to_string());
1526 pkg.vcs_status = Some(VcsTrackingInfo {
1527 branch: "ta/fix-auth".to_string(),
1528 review_url: None,
1529 review_id: None,
1530 review_state: None,
1531 commit_sha: Some("def5678".to_string()),
1532 last_checked: Utc::now(),
1533 });
1534 let json = serde_json::to_string(&pkg).unwrap();
1535 assert!(json.contains("\"tag\""));
1536 assert!(json.contains("fix-auth-01"));
1537 assert!(json.contains("\"vcs_status\""));
1538 let restored: DraftPackage = serde_json::from_str(&json).unwrap();
1539 assert_eq!(restored.tag, Some("fix-auth-01".to_string()));
1540 assert!(restored.vcs_status.is_some());
1541 }
1542
1543 #[test]
1544 fn agent_decision_log_round_trip() {
1545 let mut pkg = test_package();
1546 pkg.agent_decision_log = vec![DecisionLogEntry {
1547 decision: "Used Ed25519 instead of RSA".to_string(),
1548 rationale: "Ed25519 is faster, smaller keys, already in Cargo.lock".to_string(),
1549 alternatives: vec!["RSA-2048".to_string(), "ECDSA P-256".to_string()],
1550 alternatives_considered: vec![],
1551 confidence: Some(0.9),
1552 context: None,
1553 }];
1554 let json = serde_json::to_string(&pkg).unwrap();
1555 assert!(json.contains("agent_decision_log"));
1556 assert!(json.contains("Ed25519"));
1557 assert!(json.contains("0.9"));
1558 let restored: DraftPackage = serde_json::from_str(&json).unwrap();
1559 assert_eq!(restored.agent_decision_log.len(), 1);
1560 assert_eq!(
1561 restored.agent_decision_log[0].decision,
1562 "Used Ed25519 instead of RSA"
1563 );
1564 assert_eq!(restored.agent_decision_log[0].confidence, Some(0.9));
1565 assert_eq!(restored.agent_decision_log[0].alternatives.len(), 2);
1566 }
1567
1568 #[test]
1569 fn agent_decision_log_backward_compat() {
1570 let pkg = test_package();
1572 let json = serde_json::to_string(&pkg).unwrap();
1573 assert!(!json.contains("agent_decision_log"));
1574 let restored: DraftPackage = serde_json::from_str(&json).unwrap();
1575 assert!(restored.agent_decision_log.is_empty());
1576 }
1577
1578 #[test]
1579 fn decision_log_confidence_optional() {
1580 let entry_json = r#"{"decision":"test","rationale":"reason","alternatives":[]}"#;
1582 let entry: DecisionLogEntry = serde_json::from_str(entry_json).unwrap();
1583 assert_eq!(entry.decision, "test");
1584 assert!(entry.confidence.is_none());
1585 }
1586
1587 #[test]
1588 fn decision_log_entry_with_context() {
1589 let entry = DecisionLogEntry {
1591 decision: "Use Ollama for local inference".to_string(),
1592 rationale: "Privacy and offline requirements".to_string(),
1593 alternatives: vec![],
1594 alternatives_considered: vec![],
1595 confidence: Some(0.8),
1596 context: Some("Ollama thinking-mode config".to_string()),
1597 };
1598 let json = serde_json::to_string(&entry).unwrap();
1599 assert!(json.contains("context"));
1600 assert!(json.contains("Ollama thinking-mode config"));
1601 let restored: DecisionLogEntry = serde_json::from_str(&json).unwrap();
1602 assert_eq!(
1603 restored.context.as_deref(),
1604 Some("Ollama thinking-mode config")
1605 );
1606 assert_eq!(restored.decision, "Use Ollama for local inference");
1607 }
1608
1609 #[test]
1610 fn decision_log_entry_context_backward_compat() {
1611 let json = r#"{"decision":"Used JWT","rationale":"Scalability"}"#;
1613 let entry: DecisionLogEntry = serde_json::from_str(json).unwrap();
1614 assert!(entry.context.is_none());
1615 }
1616
1617 fn make_artifact(uri: &str) -> Artifact {
1620 Artifact {
1621 resource_uri: uri.to_string(),
1622 change_type: ChangeType::Add,
1623 diff_ref: "changeset:0".to_string(),
1624 tests_run: vec![],
1625 disposition: ArtifactDisposition::Pending,
1626 rationale: None,
1627 dependencies: vec![],
1628 explanation_tiers: None,
1629 comments: None,
1630 amendment: None,
1631 kind: None,
1632 }
1633 }
1634
1635 #[test]
1636 fn missing_decisions_fires_on_code_changes() {
1637 let mut pkg = test_package();
1638 pkg.changes
1640 .artifacts
1641 .push(make_artifact("fs://workspace/src/main.rs"));
1642 let warn = check_missing_decisions(&pkg);
1644 assert!(warn.is_some());
1645 assert!(warn.unwrap().contains("decision log"));
1646 }
1647
1648 #[test]
1649 fn missing_decisions_suppressed_when_decisions_present() {
1650 let mut pkg = test_package();
1651 pkg.changes
1652 .artifacts
1653 .push(make_artifact("fs://workspace/src/main.rs"));
1654 pkg.agent_decision_log.push(DecisionLogEntry {
1655 decision: "Used trait objects for extensibility".to_string(),
1656 rationale: "Allows plugin authors to add new adapters".to_string(),
1657 alternatives: vec!["enum dispatch".to_string()],
1658 alternatives_considered: vec![],
1659 confidence: Some(0.9),
1660 context: None,
1661 });
1662 let warn = check_missing_decisions(&pkg);
1663 assert!(warn.is_none());
1664 }
1665
1666 #[test]
1667 fn missing_decisions_suppressed_for_trivial_changes() {
1668 let mut pkg = test_package();
1669 pkg.changes
1671 .artifacts
1672 .push(make_artifact("fs://workspace/Cargo.toml"));
1673 pkg.changes
1674 .artifacts
1675 .push(make_artifact("fs://workspace/README.md"));
1676 let warn = check_missing_decisions(&pkg);
1677 assert!(warn.is_none());
1678 }
1679
1680 #[test]
1681 fn missing_decisions_fires_for_typescript_and_python() {
1682 let mut pkg = test_package();
1683 pkg.changes
1684 .artifacts
1685 .push(make_artifact("fs://workspace/src/app.ts"));
1686 let warn = check_missing_decisions(&pkg);
1687 assert!(warn.is_some());
1688
1689 let mut pkg2 = test_package();
1690 pkg2.changes
1691 .artifacts
1692 .push(make_artifact("fs://workspace/scripts/process.py"));
1693 let warn2 = check_missing_decisions(&pkg2);
1694 assert!(warn2.is_some());
1695 }
1696
1697 #[test]
1698 fn missing_decisions_suppressed_when_no_artifacts() {
1699 let pkg = test_package();
1700 let warn = check_missing_decisions(&pkg);
1702 assert!(warn.is_none());
1703 }
1704}