Skip to main content

ta_changeset/
draft_package.rs

1// draft_package.rs — Draft Package: the milestone deliverable for human review.
2//
3// A Draft Package bundles all staged changes (ChangeSets) from a goal iteration
4// into a single reviewable artifact. It includes:
5// - What changed and why (summary)
6// - The actual changes (artifacts + patch_sets)
7// - Risk assessment
8// - Provenance (where inputs came from)
9// - Review requests (what approvals are needed)
10//
11// The structure aligns with schema/draft_package.schema.json.
12
13use std::fmt;
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19use crate::artifact_kind::ArtifactKind;
20
21// ---- Goal ----
22
23/// The high-level goal this PR package contributes to.
24#[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    /// Title of the root/parent goal this is a follow-up to (v0.13.0.1).
33    /// Preserved so draft view and apply can show the chain context even if
34    /// the parent goal record is no longer available.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub parent_goal_title: Option<String>,
37}
38
39// ---- Iteration ----
40
41/// Which iteration of the goal this package represents.
42#[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/// Reference to the workspace where changes were staged.
50#[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// ---- Agent Identity ----
61
62/// Identity of the agent that produced this PR package.
63#[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// ---- Summary ----
74
75/// Human-readable summary of what changed and why.
76#[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    /// Design alternatives considered during this work (v0.9.5).
85    /// Populated by agents via the `alternatives` parameter on `ta_pr_build`.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub alternatives_considered: Vec<DesignAlternative>,
88}
89
90/// A design alternative considered during agent work (v0.9.5).
91///
92/// Agents report which options they evaluated and why they chose one over others.
93/// Displayed under "Design Decisions" in `ta draft view`.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct DesignAlternative {
96    /// The option that was considered (e.g., "Use a HashMap for lookup").
97    pub option: String,
98    /// Why this option was chosen or rejected.
99    pub rationale: String,
100    /// Whether this was the chosen approach.
101    #[serde(default)]
102    pub chosen: bool,
103}
104
105// ---- Changes ----
106
107/// The changes section: artifacts (local FS changes) + patch_sets (external changes)
108/// + pending_actions (intercepted MCP tool calls, v0.5.1).
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Changes {
111    pub artifacts: Vec<Artifact>,
112    pub patch_sets: Vec<PatchSet>,
113    /// MCP tool calls intercepted for human review (v0.5.1).
114    /// State-changing external actions are captured here instead of being
115    /// executed immediately. Read-only calls pass through unintercepted.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub pending_actions: Vec<PendingAction>,
118}
119
120/// An MCP tool call intercepted during agent execution, pending human review.
121///
122/// When an agent calls an external MCP tool (e.g., `gmail_send`, `slack_post`),
123/// TA intercepts the call, records it here, and holds it for human approval.
124/// Read-only calls (search, list, get) pass through immediately.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct PendingAction {
127    /// Unique identifier for this action instance.
128    pub action_id: Uuid,
129    /// The MCP tool name that was called (e.g., "gmail_send", "slack_post").
130    pub tool_name: String,
131    /// Serialized tool parameters as provided by the agent (credentials redacted).
132    pub parameters: serde_json::Value,
133    /// How this action was classified.
134    pub kind: ActionKind,
135    /// When the tool call was intercepted.
136    pub intercepted_at: DateTime<Utc>,
137    /// Human-readable description for the reviewer.
138    pub description: String,
139    /// Resource this action targets (URI scheme, e.g., "mcp://gmail/send").
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub target_uri: Option<String>,
142    /// Whether this action has been approved for replay.
143    #[serde(default)]
144    pub disposition: ArtifactDisposition,
145}
146
147/// How an intercepted MCP tool call is classified.
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "snake_case")]
150pub enum ActionKind {
151    /// Read-only — no side effects. Passed through without interception.
152    ReadOnly,
153    /// Produces a side effect — captured for human review.
154    StateChanging,
155    /// Cannot be automatically classified — requires human review.
156    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/// Three-tier explanation for an artifact (v0.2.3).
170///
171/// Agents populate this via `.diff.explanation.yaml` sidecar files.
172/// Enables tiered review: top (one-line) → medium (paragraph) → full (with diff).
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174pub struct ExplanationTiers {
175    /// One-line summary (e.g., "Refactored auth middleware to use JWT").
176    pub summary: String,
177    /// Paragraph explaining what changed and why, dependencies affected.
178    pub explanation: String,
179    /// Optional tags for categorization (e.g., "security", "breaking-change").
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub tags: Vec<String>,
182    /// Related artifacts (URIs) that are connected to this change.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub related_artifacts: Vec<String>,
185}
186
187/// A local filesystem change artifact.
188#[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    /// Per-artifact review disposition (defaults to Pending).
196    #[serde(default)]
197    pub disposition: ArtifactDisposition,
198    /// Why this change was made (from agent's change_summary.json).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub rationale: Option<String>,
201    /// Dependencies: other artifacts this one requires or is required by.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub dependencies: Vec<ChangeDependency>,
204    /// Three-tier explanation (summary, explanation, tags) from sidecar YAML (v0.2.3).
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub explanation_tiers: Option<ExplanationTiers>,
207    /// Comment thread for this artifact (v0.3.0 — Review Sessions).
208    /// Comments from ReviewSession are merged here during draft finalization.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub comments: Option<crate::review_session::CommentThread>,
211    /// Amendment record if this artifact was amended after initial creation (v0.3.4).
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub amendment: Option<AmendmentRecord>,
214    /// Semantic kind of the artifact (v0.14.15). When present, the renderer
215    /// uses kind-specific display logic (e.g. image artifacts suppress the
216    /// binary diff and show a human-readable frame/resolution summary).
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub kind: Option<ArtifactKind>,
219}
220
221/// Record of a human amendment to an artifact (v0.3.4).
222///
223/// Tracks who amended the artifact, when, and how — for audit trail purposes.
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct AmendmentRecord {
226    /// Who performed the amendment (e.g., "human", reviewer name).
227    pub amended_by: String,
228    /// When the amendment was made.
229    pub amended_at: DateTime<Utc>,
230    /// What kind of amendment was performed.
231    pub amendment_type: AmendmentType,
232    /// Optional reason for the amendment.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub reason: Option<String>,
235}
236
237/// The type of amendment applied to an artifact (v0.3.4).
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239#[serde(rename_all = "snake_case")]
240pub enum AmendmentType {
241    /// Artifact content replaced with a corrected file (--file).
242    FileReplaced,
243    /// A patch was applied to the artifact (--patch).
244    PatchApplied,
245    /// Artifact was removed from the draft (--drop).
246    Dropped,
247}
248
249/// Per-artifact review disposition.
250///
251/// Tracks the reviewer's decision on each individual artifact,
252/// enabling selective approval (approve some, reject others, discuss the rest).
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
254#[serde(rename_all = "snake_case")]
255pub enum ArtifactDisposition {
256    /// Not yet reviewed.
257    #[default]
258    Pending,
259    /// Approved — will be applied.
260    Approved,
261    /// Rejected — will not be applied.
262    Rejected,
263    /// Needs discussion before deciding.
264    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/// A dependency relationship between artifacts.
279///
280/// Reported by the agent via .ta/change_summary.json, used by the
281/// reviewer to understand which changes can be independently accepted/rejected.
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub struct ChangeDependency {
284    /// The resource_uri of the related artifact.
285    pub target_uri: String,
286    /// The nature of the dependency.
287    pub kind: DependencyKind,
288}
289
290/// How two artifacts are related.
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
292#[serde(rename_all = "snake_case")]
293pub enum DependencyKind {
294    /// This artifact requires the target (can't apply without it).
295    DependsOn,
296    /// The target requires this artifact (target breaks if this is reverted).
297    DependedBy,
298}
299
300/// The type of filesystem change.
301#[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/// A staged change to an external resource (Drive, Gmail, DB, etc.).
311#[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/// Action types for external patch sets.
322#[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// ---- Risk ----
334
335/// Risk assessment for the PR package.
336#[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/// A single risk finding.
344#[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/// A recorded policy decision relevant to this PR.
377#[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    /// Grants that were checked during evaluation (v0.3.3).
384    #[serde(default, skip_serializing_if = "Vec::is_empty")]
385    pub grants_checked: Vec<String>,
386    /// The grant that matched (if any) (v0.3.3).
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub matching_grant: Option<String>,
389    /// Evaluation steps the policy engine performed (v0.3.3).
390    #[serde(default, skip_serializing_if = "Vec::is_empty")]
391    pub evaluation_steps: Vec<String>,
392}
393
394// ---- Provenance ----
395
396/// Provenance information: where inputs came from.
397#[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// ---- Review Requests ----
422
423/// What approvals this PR needs.
424#[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// ---- Signatures ----
445
446/// Cryptographic signatures for the PR package.
447#[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// ---- Approval Record ----
456
457/// Records a single reviewer's approval of a draft package (v0.14.2).
458///
459/// Multiple `ApprovalRecord`s accumulate in `DraftPackage::pending_approvals`
460/// until the governance quorum is reached, at which point the draft transitions
461/// to `DraftStatus::Approved`.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct ApprovalRecord {
464    /// Reviewer identity (name or email).
465    pub reviewer: String,
466    /// When this approval was recorded.
467    pub approved_at: DateTime<Utc>,
468}
469
470// ---- Draft Package (top level) ----
471
472/// The Draft Package — a complete, reviewable milestone deliverable.
473///
474/// This is the central artifact of Trusted Autonomy. Every goal iteration
475/// produces one of these for human review.
476#[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    /// Tracks the review status (not in the JSON schema — internal state).
493    #[serde(default)]
494    pub status: DraftStatus,
495
496    /// Verification warnings from pre-draft verification gate (v0.10.8).
497    /// Populated when `[verify] on_failure = "warn"` and a command fails.
498    #[serde(default, skip_serializing_if = "Vec::is_empty")]
499    pub verification_warnings: Vec<VerificationWarning>,
500
501    /// Hard evidence that required checks passed/failed (v0.13.17).
502    /// Each entry records the outcome of one required_check command.
503    /// Non-zero exit_code blocks `ta draft approve` unless --override is passed.
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    pub validation_log: Vec<ValidationEntry>,
506
507    /// Human-friendly display ID derived from the goal ID (v0.10.11).
508    /// Format: `<goal-id-prefix>-NN` (e.g., `511e0465-01`).
509    /// Falls back to `package_id` short prefix for legacy drafts.
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub display_id: Option<String>,
512
513    /// Human-friendly goal tag inherited from the parent GoalRun (v0.11.2.3).
514    /// The primary display identifier in all draft listing contexts.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub tag: Option<String>,
517
518    /// VCS tracking information populated during commit/push/open_review (v0.11.2.3).
519    /// Tracks the PR lifecycle after apply.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub vcs_status: Option<VcsTrackingInfo>,
522
523    /// Parent draft ID for follow-up goals (v0.12.2.1).
524    /// When set, this draft is a follow-up to the parent draft. Used for chain
525    /// display (`ta draft view` combined impact) and chain apply (`ta draft apply --chain`).
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub parent_draft_id: Option<Uuid>,
528
529    /// Accumulated reviewer approvals for multi-party governance (v0.14.2).
530    /// Empty for single-approver workflows (legacy / require_approvals = 1).
531    /// Grows as each reviewer calls `ta draft approve --as <identity>`.
532    /// When `pending_approvals.len() >= require_approvals` the draft transitions
533    /// to `DraftStatus::Approved`.
534    #[serde(default, skip_serializing_if = "Vec::is_empty")]
535    pub pending_approvals: Vec<ApprovalRecord>,
536
537    /// AI supervisor review embedded after agent exit (v0.13.17.4).
538    /// Present when supervisor is enabled; `None` when disabled or skipped.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub supervisor_review: Option<crate::supervisor_review::SupervisorReview>,
541
542    /// Gitignored artifacts encountered during apply --submit (v0.13.17.5).
543    /// Populated by the VCS adapter when `git add` would fail on ignored paths.
544    /// Known-safe artifacts (.mcp.json, *.local.toml, .ta/ runtime files) are
545    /// recorded here but silently dropped from the commit.
546    /// Unexpected-ignored artifacts are highlighted in `ta draft view`.
547    #[serde(default, skip_serializing_if = "Vec::is_empty")]
548    pub ignored_artifacts: Vec<IgnoredArtifact>,
549
550    /// Artifact URIs inherited from the parent draft (v0.14.3.5).
551    ///
552    /// Populated when building a follow-up draft. Contains all `resource_uri` values
553    /// from the parent draft's artifact list at build time. During `ta draft apply`,
554    /// files in this list that are unchanged in staging (staging hash == source hash)
555    /// are skipped — they were already settled by the parent apply and staging just
556    /// has an older copy.
557    ///
558    /// This prevents "follow-up staging drift": applying a follow-up draft from
559    /// staging that predates the parent commit would otherwise revert files
560    /// (PLAN.md, USAGE.md, shared source) that the parent apply had already updated.
561    #[serde(default, skip_serializing_if = "Vec::is_empty")]
562    pub baseline_artifacts: Vec<String>,
563
564    /// Agent-authored decision log (v0.14.7).
565    ///
566    /// Populated from `.ta-decisions.json` written by the agent during its run.
567    /// Records non-obvious implementation choices with alternatives and rationale.
568    /// Distinct from `plan.decision_log` which is extracted from `change_summary.json`.
569    /// Shown as the "Agent Decision Log" section in `ta draft view`.
570    #[serde(default, skip_serializing_if = "Vec::is_empty")]
571    pub agent_decision_log: Vec<DecisionLogEntry>,
572
573    /// Goal shortref inherited from the parent GoalRun (v0.14.7.3).
574    ///
575    /// First 8 lowercase hex characters of `goal.goal_id`. Populated at `ta draft build`
576    /// time. Used to display drafts as `<goal_shortref>/<draft_seq>` across all CLI
577    /// surfaces (list, view, PR title, audit log). Allows `grep 2159d87e audit.jsonl`
578    /// to find all entries for a goal.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub goal_shortref: Option<String>,
581
582    /// Sequence number for this draft within its goal (v0.14.7.3).
583    ///
584    /// First draft for a goal is 1, second is 2, etc. Combined with `goal_shortref`
585    /// to produce the `<shortref>/<seq>` display identifier (e.g., `2159d87e/1`).
586    /// Defaults to 0 for legacy drafts without this field.
587    #[serde(default)]
588    pub draft_seq: u32,
589
590    /// Plan phase ID linked to this draft (v0.15.15.2).
591    ///
592    /// Populated from `GoalRun.plan_phase` at `ta draft build` time.
593    /// Shown prominently in `ta draft view` (below the draft title) and as
594    /// a column in `ta draft list`. Empty for goals not linked to a plan phase.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub plan_phase: Option<String>,
597}
598
599/// VCS tracking information for post-apply lifecycle monitoring (v0.11.2.3).
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct VcsTrackingInfo {
602    /// Branch name the changes were committed to.
603    pub branch: String,
604    /// PR/review URL (e.g., GitHub PR URL).
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub review_url: Option<String>,
607    /// PR/review identifier (e.g., PR number).
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub review_id: Option<String>,
610    /// PR/review state: "open", "merged", "closed".
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub review_state: Option<String>,
613    /// Commit SHA of the applied changes.
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub commit_sha: Option<String>,
616    /// When VCS status was last checked/updated.
617    pub last_checked: DateTime<Utc>,
618}
619
620/// A warning from a pre-draft verification command failure (v0.10.8).
621#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct VerificationWarning {
623    /// The command that failed.
624    pub command: String,
625    /// The exit code (if available).
626    pub exit_code: Option<i32>,
627    /// Captured stderr/stdout output (truncated to 2000 chars).
628    pub output: String,
629}
630
631/// A gitignored artifact encountered during `ta draft apply --submit` (v0.13.17.5).
632///
633/// Classified as either known-safe (silently dropped) or unexpected (requires attention).
634#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
635pub struct IgnoredArtifact {
636    /// Relative path of the artifact that was gitignored.
637    pub path: String,
638    /// Whether the path matched the known-safe drop list (e.g., .mcp.json).
639    pub known_safe: bool,
640}
641
642/// Result of one `required_checks` entry run after agent exit (v0.13.17).
643#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
644pub struct ValidationEntry {
645    /// The command that was run.
646    pub command: String,
647    /// Exit code (0 = success).
648    pub exit_code: i32,
649    /// How long the command took.
650    pub duration_secs: u64,
651    /// Last 20 lines of combined stdout+stderr output.
652    pub stdout_tail: String,
653}
654
655/// Execution plan included in the PR package.
656#[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    /// Structured alternatives with rejection reasons (v0.3.3).
671    #[serde(default, skip_serializing_if = "Vec::is_empty")]
672    pub alternatives_considered: Vec<AlternativeConsidered>,
673    /// Optional agent confidence in this decision (0.0–1.0) (v0.14.7).
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub confidence: Option<f32>,
676    /// What external need, feature, or constraint triggered this decision (v0.14.9.2).
677    /// Shown as the header line in collapsed state.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub context: Option<String>,
680}
681
682/// A structured alternative considered during a decision (v0.3.3).
683#[derive(Debug, Clone, Serialize, Deserialize)]
684pub struct AlternativeConsidered {
685    pub description: String,
686    pub rejected_reason: String,
687}
688
689/// How a draft was applied — provenance for [`DraftStatus::Applied`] (v0.15.14.0).
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
691#[serde(tag = "via", rename_all = "snake_case")]
692pub enum ApplyProvenance {
693    /// Triggered by an explicit `ta draft apply` CLI invocation.
694    #[default]
695    Manual,
696    /// Triggered by a background or agent task.
697    BackgroundTask { task_id: String },
698    /// Triggered by an auto-merge hook.
699    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/// Review status of a draft package (internal tracking, not in JSON schema).
713#[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        /// How the draft was applied (v0.15.14.0). Defaults to `Manual` for
730        /// backward-compatible deserialization of older draft files.
731        #[serde(default)]
732        applied_via: ApplyProvenance,
733    },
734    /// This draft has been superseded by a follow-up goal's draft.
735    Superseded {
736        superseded_by: Uuid,
737    },
738    /// This draft has been manually closed (abandoned, hand-merged, or obsolete).
739    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/// Create a minimal valid [`DraftPackage`] for testing with the given goal
761/// shortref and draft sequence number.
762///
763/// Only available in test builds. Used by `draft_resolver` unit tests and
764/// any other test that needs a lightweight package fixture.
765#[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
851/// Check whether a draft is missing an agent decision log for substantive changes.
852///
853/// "Substantive" = any artifact that is a `.rs`, `.ts`, `.tsx`, `.js`, `.jsx`,
854/// `.py`, `.go`, `.java`, `.cpp`, `.c`, or `.h` file. Config-only changes
855/// (`.toml`, `.yaml`, `.json`, `.md`, docs) are excluded from this check.
856///
857/// Returns a warning annotation string when the check fires, or `None` if
858/// a decision log is present or there are no substantive code changes.
859///
860/// This satisfies Constitution §1.5: reviewers must have design rationale for
861/// any goal that creates or modifies non-trivial source code.
862pub fn check_missing_decisions(pkg: &DraftPackage) -> Option<String> {
863    // No warning if decision log is present.
864    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        // Extract file extension from URI like "fs://workspace/src/main.rs"
875        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    /// Helper to create a minimal valid draft package for testing.
902    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        // Draft → PendingReview
1020        let status = DraftStatus::PendingReview;
1021        assert_eq!(status.to_string(), "pending_review");
1022
1023        // PendingReview → Approved
1024        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        // PendingReview → Denied
1031        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        // Approved → Applied
1038        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        // Verify the serialized JSON includes all required fields from the schema.
1054        let pkg = test_package();
1055        let json = serde_json::to_string(&pkg).unwrap();
1056
1057        // Check required top-level fields from draft_package.schema.json
1058        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        // Backward compatibility: old JSON without disposition/rationale/dependencies.
1147        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        // Backward compatibility: old JSON without explanation_tiers.
1265        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    // ── v0.3.3 Decision Observability tests ──
1275
1276    #[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        // Old JSON without alternatives_considered should deserialize fine.
1313        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        // Old JSON without v0.3.3 fields should deserialize fine.
1347        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    // ── v0.3.4 Draft Amendment tests ──
1359
1360    #[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        // Old JSON without amendment field should deserialize fine.
1426        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    // ── v0.9.5 Design Alternatives tests ──
1436
1437    #[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        // JSON without tag/vcs_status should deserialize fine (backward compat).
1512        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        // Packages without agent_decision_log should deserialize with empty vec.
1571        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        // DecisionLogEntry without confidence should deserialize fine.
1581        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        // Serialization round-trip with context field (v0.14.9.2).
1590        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        // Old JSON without context should deserialize with context: None (v0.14.9.2).
1612        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    // ── check_missing_decisions (v0.15.15.1) ─────────────────────────────────
1618
1619    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        // Add a Rust file artifact — substantive code change.
1639        pkg.changes
1640            .artifacts
1641            .push(make_artifact("fs://workspace/src/main.rs"));
1642        // No decision log entries.
1643        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        // Only toml + md files — no substantive code.
1670        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        // No artifacts at all.
1701        let warn = check_missing_decisions(&pkg);
1702        assert!(warn.is_none());
1703    }
1704}