Skip to main content

plan_issue/
lifecycle_record.rs

1use std::collections::BTreeMap;
2
3use nils_markdown::Engine;
4use serde::Serialize;
5use serde_json::Value;
6
7use crate::commands::record::{LifecycleCommentKind, RecordProfile, TaskLedgerDisplay};
8
9const DASHBOARD_TEMPLATE: &str = include_str!("../templates/lifecycle_record/dashboard.md.tera");
10const DASHBOARD_TEMPLATE_NAME: &str = "lifecycle_record_dashboard";
11
12const SNAPSHOT_TEMPLATE: &str = include_str!("../templates/lifecycle_record/snapshot.md.tera");
13const SNAPSHOT_TEMPLATE_NAME: &str = "lifecycle_record_snapshot";
14
15const POST_COMMENT_TEMPLATE: &str =
16    include_str!("../templates/lifecycle_record/post_comment.md.tera");
17const POST_COMMENT_TEMPLATE_NAME: &str = "lifecycle_record_post_comment";
18
19const STATE_VISIBLE_TEMPLATE: &str = include_str!("../templates/lifecycle_record/state.md.tera");
20const STATE_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_state";
21
22const SESSION_VISIBLE_TEMPLATE: &str =
23    include_str!("../templates/lifecycle_record/session.md.tera");
24const SESSION_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_session";
25
26const VALIDATION_VISIBLE_TEMPLATE: &str =
27    include_str!("../templates/lifecycle_record/validation.md.tera");
28const VALIDATION_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_validation";
29
30const REVIEW_VISIBLE_TEMPLATE: &str = include_str!("../templates/lifecycle_record/review.md.tera");
31const REVIEW_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_review";
32
33const CLOSEOUT_VISIBLE_TEMPLATE: &str =
34    include_str!("../templates/lifecycle_record/closeout.md.tera");
35const CLOSEOUT_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_closeout";
36
37#[derive(Debug, Serialize)]
38struct PostCommentView<'a> {
39    marker: String,
40    heading: &'static str,
41    profile: &'a str,
42    visible_content: String,
43    envelope_carrier: String,
44}
45
46#[derive(Debug, Serialize)]
47struct StateVisibleView<'a> {
48    status: Option<&'static str>,
49    target_scope: Option<&'a str>,
50    current: Option<&'a str>,
51    next_action: Option<&'a str>,
52    tasks: Vec<StateTaskRow>,
53}
54
55#[derive(Debug, Serialize)]
56struct StateTaskRow {
57    id: String,
58    status: &'static str,
59    title: String,
60}
61
62#[derive(Debug, Serialize)]
63struct SessionVisibleView<'a> {
64    summary: &'a str,
65    highlights: Vec<String>,
66    links: Vec<KeyValuePair>,
67    extras: Vec<KeyValuePair>,
68}
69
70#[derive(Debug, Serialize)]
71struct KeyValuePair {
72    key: String,
73    value: String,
74}
75
76#[derive(Debug, Serialize)]
77struct ValidationVisibleView<'a> {
78    overall: &'static str,
79    commands: Vec<ValidationCommandRow<'a>>,
80    waivers: Vec<ValidationWaiverRow<'a>>,
81}
82
83#[derive(Debug, Serialize)]
84struct ValidationCommandRow<'a> {
85    command: String,
86    status: &'static str,
87    evidence: String,
88    _phantom: std::marker::PhantomData<&'a ()>,
89}
90
91#[derive(Debug, Serialize)]
92struct ValidationWaiverRow<'a> {
93    command: &'a str,
94    reason: &'a str,
95}
96
97#[derive(Debug, Serialize)]
98struct ReviewVisibleView<'a> {
99    decision: &'static str,
100    lenses: Option<String>,
101    outcome_comment_url: Option<&'a str>,
102    findings: Vec<ReviewFindingRow>,
103}
104
105#[derive(Debug, Serialize)]
106struct ReviewFindingRow {
107    id: String,
108    severity: &'static str,
109    disposition: &'static str,
110    summary: String,
111}
112
113#[derive(Debug, Serialize)]
114struct CloseoutVisibleView<'a> {
115    final_status: &'a str,
116    approver: Option<&'a str>,
117    approval_url: Option<&'a str>,
118    final_validation_url: Option<&'a str>,
119    notes: Option<&'a str>,
120    has_override: bool,
121    override_reason: Option<String>,
122    override_failures: Option<String>,
123    linked_prs: Vec<CloseoutPrRow>,
124}
125
126#[derive(Debug, Serialize)]
127struct CloseoutPrRow {
128    label: String,
129    merge_sha: String,
130    checks: &'static str,
131    required: String,
132    non_required_failures: String,
133}
134
135#[derive(Debug, Serialize)]
136struct SnapshotView<'a> {
137    marker: String,
138    heading: &'static str,
139    profile: &'a str,
140    path: Option<&'a str>,
141    commit: Option<&'a str>,
142    summary: Option<&'a str>,
143    details_summary: &'static str,
144    content: &'a str,
145    envelope_carrier: String,
146}
147
148#[derive(Debug, Serialize)]
149struct DashboardView<'a> {
150    title: &'static str,
151    status: String,
152    profile: &'a str,
153    target_scope: String,
154    current: String,
155    next_action: String,
156    validation: String,
157    linked_prs: String,
158    blockers: String,
159    approval: String,
160    source_link: String,
161    plan_link: String,
162    state_link: String,
163    session_link: String,
164    validation_link: String,
165    review_link: String,
166    show_review: bool,
167    closeout_link: String,
168    tracker_block: String,
169}
170
171fn render_tracker_block(title: Option<&str>, issue_url: Option<&str>) -> String {
172    let title = title.map(str::trim).filter(|value| !value.is_empty());
173    let issue_url = issue_url.map(str::trim).filter(|value| !value.is_empty());
174    if title.is_none() && issue_url.is_none() {
175        return String::new();
176    }
177    let mut out = vec![
178        String::new(),
179        "## Original Tracker".to_string(),
180        String::new(),
181    ];
182    if let Some(value) = title {
183        out.push(format!("- Title: {value}"));
184    }
185    if let Some(value) = issue_url {
186        out.push(format!("- Issue: {value}"));
187    }
188    out.join("\n")
189}
190
191fn render_dashboard_with_template(view: &DashboardView<'_>) -> String {
192    let mut engine = Engine::builder().build();
193    engine
194        .register_template(DASHBOARD_TEMPLATE_NAME, DASHBOARD_TEMPLATE)
195        .expect("dashboard template registers");
196    engine
197        .render(DASHBOARD_TEMPLATE_NAME, view)
198        .expect("dashboard template renders")
199}
200
201#[derive(Debug, Clone)]
202pub struct DashboardInput {
203    pub profile: RecordProfile,
204    pub status: String,
205    pub target_scope: String,
206    pub current: String,
207    pub next_action: String,
208    pub validation: String,
209    pub linked_prs: Vec<String>,
210    pub blockers: Vec<String>,
211    pub approval: String,
212    pub source_url: Option<String>,
213    pub plan_url: Option<String>,
214    pub state_url: Option<String>,
215    pub session_url: Option<String>,
216    pub validation_url: Option<String>,
217    pub review_url: Option<String>,
218    pub closeout_url: Option<String>,
219    pub title: Option<String>,
220    pub issue_url: Option<String>,
221}
222
223#[derive(Debug, Clone)]
224pub struct CommentInput {
225    pub profile: RecordProfile,
226    pub kind: LifecycleCommentKind,
227    pub path: Option<String>,
228    pub commit: Option<String>,
229    pub content: Option<String>,
230    pub title: Option<String>,
231    pub details_summary: Option<String>,
232}
233
234#[derive(Debug, Clone, Serialize)]
235pub struct LifecycleEvidence {
236    pub role: PayloadRole,
237    pub profile: PayloadProfile,
238    pub url: Option<String>,
239    pub created_at: Option<String>,
240    /// Stable status string derived from the structured payload (e.g.
241    /// state `status`, validation `overall`, review `decision`). `None`
242    /// when the role does not declare a status or the payload could not
243    /// be parsed.
244    pub status: Option<String>,
245    /// Parsed structured payload. `None` when the comment lacks a hidden
246    /// payload carrier or older `plan-issue-record-payload` fence; audit
247    /// still records the marker for visibility but downstream gates treat
248    /// missing payloads as unparseable evidence.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub payload: Option<RecordPayload>,
251}
252
253#[derive(Debug, Clone, Serialize)]
254pub struct UnsupportedMarker {
255    pub marker_prefix: String,
256    pub url: Option<String>,
257    pub created_at: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize)]
261pub struct RecordAudit {
262    pub profile_filter: Option<String>,
263    pub body_sections: BodySections,
264    /// Latest v2 lifecycle evidence indexed by role name (`source`,
265    /// `plan`, `state`, `session`, `validation`, `review`, `closeout`).
266    pub evidence: BTreeMap<String, LifecycleEvidence>,
267    /// Stable machine-readable codes for missing required evidence
268    /// (e.g. `source-missing`, `plan-missing`, `state-missing`).
269    pub missing_required: Vec<String>,
270    /// Pre-v2 markers seen during audit. Reported for visibility but not
271    /// counted as current lifecycle evidence.
272    pub unsupported_markers: Vec<UnsupportedMarker>,
273    pub recognized_count: usize,
274    #[serde(skip_serializing)]
275    pub evidence_text: String,
276}
277
278#[derive(Debug, Clone, Serialize)]
279pub struct BodySections {
280    pub current_dashboard: bool,
281    pub final_dashboard: bool,
282    pub durable_record: bool,
283    pub closeout_checks: bool,
284    pub task_decomposition: bool,
285}
286
287#[derive(Debug, Clone, Serialize)]
288pub struct CloseoutCheck {
289    pub check: String,
290    pub status: String,
291    pub detail: String,
292}
293
294#[derive(Debug)]
295struct CommentJson {
296    body: Option<String>,
297    url: Option<String>,
298    html_url: Option<String>,
299    created_at: Option<String>,
300}
301
302pub fn render_dashboard(input: DashboardInput) -> String {
303    let title = if input.status.trim().eq_ignore_ascii_case("complete") {
304        "## Final Dashboard"
305    } else {
306        "## Current Dashboard"
307    };
308
309    let show_review = input.profile == RecordProfile::Dispatch || input.review_url.is_some();
310    let tracker_block = render_tracker_block(input.title.as_deref(), input.issue_url.as_deref());
311
312    let view = DashboardView {
313        title,
314        status: input.status.trim().to_string(),
315        profile: input.profile.as_str(),
316        target_scope: input.target_scope.trim().to_string(),
317        current: input.current.trim().to_string(),
318        next_action: input.next_action.trim().to_string(),
319        validation: input.validation.trim().to_string(),
320        linked_prs: non_empty_join(&input.linked_prs, "none yet"),
321        blockers: non_empty_join(&input.blockers, "none"),
322        approval: input.approval.trim().to_string(),
323        source_link: dashboard_link(input.source_url.as_deref(), "source snapshot"),
324        plan_link: dashboard_link(input.plan_url.as_deref(), "plan snapshot"),
325        state_link: dashboard_link(input.state_url.as_deref(), "execution state"),
326        session_link: dashboard_link(input.session_url.as_deref(), "Execution Session"),
327        validation_link: dashboard_link(input.validation_url.as_deref(), "Validation Evidence"),
328        review_link: dashboard_link(input.review_url.as_deref(), "Review Evidence"),
329        show_review,
330        closeout_link: dashboard_link(input.closeout_url.as_deref(), "closeout"),
331        tracker_block,
332    };
333
334    render_dashboard_with_template(&view)
335}
336
337pub fn render_comment(input: CommentInput) -> Result<String, String> {
338    let marker = marker_for(input.profile, input.kind);
339    let heading = input
340        .title
341        .clone()
342        .unwrap_or_else(|| default_heading(input.profile, input.kind).to_string());
343    let content = input.content.unwrap_or_default();
344
345    let mut out = Vec::new();
346    out.push(marker);
347    out.push(String::new());
348    out.push(format!("## {heading}"));
349    out.push(String::new());
350
351    out.push(format!("- Profile: {}", input.profile.as_str()));
352    if let Some(path) = input
353        .path
354        .as_deref()
355        .filter(|value| !value.trim().is_empty())
356    {
357        out.push(format!("- Path: `{}`", path.trim()));
358    }
359    if let Some(commit) = input
360        .commit
361        .as_deref()
362        .filter(|value| !value.trim().is_empty())
363    {
364        out.push(format!("- Commit: `{}`", commit.trim()));
365    }
366
367    if matches!(
368        input.kind,
369        LifecycleCommentKind::Source | LifecycleCommentKind::Plan
370    ) {
371        if !out.last().is_some_and(String::is_empty) {
372            out.push(String::new());
373        }
374        out.push("- Snapshot mode: local committed Markdown".to_string());
375        out.push(String::new());
376        out.push("<details>".to_string());
377        out.push(format!(
378            "<summary>{}</summary>",
379            input
380                .details_summary
381                .as_deref()
382                .unwrap_or_else(|| default_details_summary(input.kind))
383        ));
384        out.push(String::new());
385        out.push(content);
386        out.push(String::new());
387        out.push("</details>".to_string());
388    } else if !content.trim().is_empty() {
389        out.push(String::new());
390        out.push(content);
391    }
392
393    Ok(finalize_markdown(out))
394}
395
396pub fn audit_record(
397    body: Option<&str>,
398    comments_json: &str,
399    profile_filter: Option<RecordProfile>,
400) -> Result<RecordAudit, String> {
401    let mut comments = parse_comments_json(comments_json)?;
402    comments.sort_by(|left, right| {
403        compare_created_at(right.created_at.as_deref(), left.created_at.as_deref())
404    });
405    let mut evidence: BTreeMap<String, LifecycleEvidence> = BTreeMap::new();
406    let mut unsupported_markers = Vec::new();
407    let mut recognized_count = 0usize;
408    let mut evidence_text = String::new();
409    if let Some(body) = body {
410        evidence_text.push_str(body);
411        evidence_text.push('\n');
412    }
413
414    for comment in comments {
415        let Some(comment_body) = comment.body.as_deref() else {
416            continue;
417        };
418        let Some(first_marker) = first_comment_marker(comment_body) else {
419            continue;
420        };
421        let Some(parsed) = parse_marker_line(first_marker) else {
422            continue;
423        };
424        match parsed {
425            MarkerParse::V2 { role, profile } => {
426                if profile_filter.is_some_and(|expected| profile != PayloadProfile::from(expected))
427                {
428                    continue;
429                }
430                let url = comment.url.clone().or_else(|| comment.html_url.clone());
431                let created_at = comment.created_at.clone();
432                let key = role.as_str().to_string();
433                if evidence.contains_key(&key) {
434                    continue;
435                }
436                let payload = match extract_payload(comment_body) {
437                    Ok(payload) => Some(payload),
438                    Err(err) if err.kind == PayloadErrorKind::NoFence => None,
439                    Err(err) => {
440                        return Err(format!(
441                            "comment at {} has malformed payload: {}",
442                            url.as_deref().unwrap_or("(unknown url)"),
443                            err.message
444                        ));
445                    }
446                };
447                if let Some(payload) = payload.as_ref() {
448                    validate_payload_data_for_role(payload.role, &payload.data).map_err(|err| {
449                        format!(
450                            "comment at {} has malformed payload for role `{}`: {}",
451                            url.as_deref().unwrap_or("(unknown url)"),
452                            payload.role.as_str(),
453                            err
454                        )
455                    })?;
456                }
457                let status = payload.as_ref().and_then(derive_status_from_payload);
458                let candidate = LifecycleEvidence {
459                    role,
460                    profile,
461                    url,
462                    created_at: created_at.clone(),
463                    status,
464                    payload,
465                };
466                evidence_text.push_str(comment_body);
467                evidence_text.push('\n');
468                recognized_count += 1;
469                evidence.insert(key, candidate);
470            }
471            MarkerParse::Unsupported { prefix } => {
472                unsupported_markers.push(UnsupportedMarker {
473                    marker_prefix: prefix,
474                    url: comment.url.or(comment.html_url),
475                    created_at: comment.created_at,
476                });
477            }
478        }
479    }
480
481    let mut missing_required = Vec::new();
482    for code in ["source-missing", "plan-missing", "state-missing"] {
483        let role_key = code.trim_end_matches("-missing");
484        if !evidence.contains_key(role_key) {
485            missing_required.push(code.to_string());
486        }
487    }
488
489    Ok(RecordAudit {
490        profile_filter: profile_filter.map(|profile| profile.as_str().to_string()),
491        body_sections: inspect_body_sections(body.unwrap_or_default()),
492        evidence,
493        missing_required,
494        unsupported_markers,
495        recognized_count,
496        evidence_text,
497    })
498}
499
500/// Return the latest visible comment body per lifecycle role, indexed by
501/// [`PayloadRole`]. Mirrors the latest-per-role selection inside
502/// [`audit_record`] and is the input the visible-completeness lint operates
503/// on (see [`crate::lifecycle_vnext::visible_lint`]).
504///
505/// `profile_filter` matches the same semantics as [`audit_record`] —
506/// comments whose marker carries a different profile are skipped.
507pub fn latest_role_bodies(
508    comments_json: &str,
509    profile_filter: Option<crate::commands::record::RecordProfile>,
510) -> Result<BTreeMap<PayloadRole, String>, String> {
511    let mut comments = parse_comments_json(comments_json)?;
512    comments.sort_by(|left, right| {
513        compare_created_at(right.created_at.as_deref(), left.created_at.as_deref())
514    });
515    let mut bodies: BTreeMap<PayloadRole, String> = BTreeMap::new();
516    for comment in comments {
517        let Some(body) = comment.body.as_deref() else {
518            continue;
519        };
520        let Some(first_marker) = first_comment_marker(body) else {
521            continue;
522        };
523        let Some(parsed) = parse_marker_line(first_marker) else {
524            continue;
525        };
526        if let MarkerParse::V2 { role, profile } = parsed {
527            if profile_filter.is_some_and(|expected| profile != PayloadProfile::from(expected)) {
528                continue;
529            }
530            bodies.entry(role).or_insert_with(|| body.to_string());
531        }
532    }
533    Ok(bodies)
534}
535
536/// Compare two RFC3339 created-at strings. `None` is considered older than
537/// any `Some(_)`. This keeps latest-by-role selection deterministic even
538/// when GitHub returns comments out of order.
539fn compare_created_at(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering {
540    match (left, right) {
541        (Some(l), Some(r)) => l.cmp(r),
542        (Some(_), None) => std::cmp::Ordering::Greater,
543        (None, Some(_)) => std::cmp::Ordering::Less,
544        (None, None) => std::cmp::Ordering::Equal,
545    }
546}
547
548/// Derive a stable visible status string from a parsed payload, suitable
549/// for dashboard rendering and closeout gating. Returns `None` when the
550/// role does not declare a status or the payload's status field is
551/// unparseable.
552fn derive_status_from_payload(payload: &RecordPayload) -> Option<String> {
553    match payload.role {
554        PayloadRole::State => payload
555            .parse_state()
556            .ok()
557            .and_then(|data| data.status.map(|s| status_state_label(s).to_string())),
558        PayloadRole::Validation => payload
559            .parse_validation()
560            .ok()
561            .map(|data| validation_overall_label(data.overall).to_string()),
562        PayloadRole::Review => payload
563            .parse_review()
564            .ok()
565            .map(|data| review_decision_label(data.decision).to_string()),
566        PayloadRole::Closeout => payload.parse_closeout().ok().map(|data| data.final_status),
567        PayloadRole::Source | PayloadRole::Plan | PayloadRole::Session => None,
568    }
569}
570
571fn status_state_label(value: StateStatus) -> &'static str {
572    match value {
573        StateStatus::InProgress => "in-progress",
574        StateStatus::Complete => "complete",
575        StateStatus::Blocked => "blocked",
576    }
577}
578
579fn validation_overall_label(value: ValidationOverall) -> &'static str {
580    match value {
581        ValidationOverall::Pass => "pass",
582        ValidationOverall::Fail => "fail",
583        ValidationOverall::Partial => "partial",
584    }
585}
586
587fn review_decision_label(value: ReviewDecision) -> &'static str {
588    match value {
589        ReviewDecision::Approve => "approve",
590        ReviewDecision::RequestChanges => "request-changes",
591        ReviewDecision::CommentsOnly => "comments-only",
592    }
593}
594
595/// Render the canonical dashboard for an issue-backed plan record from
596/// audit evidence alone — callers no longer need to pass every per-role
597/// URL. Returns a `## Final Dashboard` when the latest state payload
598/// reports `status=complete`, otherwise `## Current Dashboard`. Pending
599/// roles render as `pending` so the dashboard remains idempotent across
600/// repeated calls with the same evidence.
601pub fn render_dashboard_from_audit(
602    audit: &RecordAudit,
603    title: Option<&str>,
604    issue_url: Option<&str>,
605) -> String {
606    let state_evidence = audit.evidence.get("state");
607    let state_data = state_evidence
608        .and_then(|hit| hit.payload.as_ref())
609        .and_then(|payload| payload.parse_state().ok());
610    let is_complete = state_evidence
611        .and_then(|hit| hit.status.as_deref())
612        .map(|status| status.eq_ignore_ascii_case("complete"))
613        .unwrap_or(false);
614
615    let dashboard_title = if is_complete {
616        "## Final Dashboard"
617    } else {
618        "## Current Dashboard"
619    };
620
621    let profile_str = state_evidence
622        .map(|hit| hit.profile.as_str().to_string())
623        .or_else(|| {
624            audit
625                .evidence
626                .values()
627                .next()
628                .map(|hit| hit.profile.as_str().to_string())
629        })
630        .unwrap_or_else(|| "tracking".to_string());
631
632    let status_value = state_evidence
633        .and_then(|hit| hit.status.clone())
634        .unwrap_or_else(|| "pending".to_string());
635
636    let target_scope = state_data
637        .as_ref()
638        .and_then(|data| data.target_scope.clone())
639        .unwrap_or_else(|| "pending".to_string());
640    let current = state_data
641        .as_ref()
642        .and_then(|data| data.current.clone())
643        .unwrap_or_else(|| "pending".to_string());
644    let next_action = state_data
645        .as_ref()
646        .and_then(|data| data.next_action.clone())
647        .unwrap_or_else(|| "pending".to_string());
648    let validation_status = audit
649        .evidence
650        .get("validation")
651        .and_then(|hit| hit.status.clone())
652        .unwrap_or_else(|| "pending".to_string());
653    let linked_prs = state_data
654        .as_ref()
655        .map(|data| {
656            data.prs
657                .iter()
658                .map(|pr| pr.url.clone().unwrap_or_else(|| pr.pr_ref.clone()))
659                .collect::<Vec<_>>()
660        })
661        .unwrap_or_default();
662    let blockers = state_data
663        .as_ref()
664        .map(|data| data.blockers.clone())
665        .unwrap_or_default();
666    let approval = audit
667        .evidence
668        .get("closeout")
669        .and_then(|hit| hit.payload.as_ref())
670        .and_then(|payload| payload.parse_closeout().ok())
671        .and_then(|data| data.approval.comment_url)
672        .unwrap_or_else(|| "pending".to_string());
673
674    let is_dispatch_profile = profile_str == "dispatch";
675    let show_review = is_dispatch_profile || audit.evidence.contains_key("review");
676    let tracker_block = render_tracker_block(title, issue_url);
677
678    let view = DashboardView {
679        title: dashboard_title,
680        status: status_value,
681        profile: &profile_str,
682        target_scope,
683        current,
684        next_action,
685        validation: validation_status,
686        linked_prs: non_empty_join(&linked_prs, "none yet"),
687        blockers: non_empty_join(&blockers, "none"),
688        approval,
689        source_link: dashboard_link(evidence_url(audit, "source").as_deref(), "source snapshot"),
690        plan_link: dashboard_link(evidence_url(audit, "plan").as_deref(), "plan snapshot"),
691        state_link: dashboard_link(evidence_url(audit, "state").as_deref(), "execution state"),
692        session_link: dashboard_link(
693            evidence_url(audit, "session").as_deref(),
694            "Execution Session",
695        ),
696        validation_link: dashboard_link(
697            evidence_url(audit, "validation").as_deref(),
698            "Validation Evidence",
699        ),
700        review_link: dashboard_link(evidence_url(audit, "review").as_deref(), "Review Evidence"),
701        show_review,
702        closeout_link: dashboard_link(evidence_url(audit, "closeout").as_deref(), "closeout"),
703        tracker_block,
704    };
705
706    render_dashboard_with_template(&view)
707}
708
709fn evidence_url(audit: &RecordAudit, role: &str) -> Option<String> {
710    audit
711        .evidence
712        .get(role)
713        .and_then(|hit| hit.url.clone())
714        .filter(|value| !value.trim().is_empty())
715}
716
717fn non_empty_join(values: &[String], fallback: &str) -> String {
718    let joined = values
719        .iter()
720        .map(|value| value.trim())
721        .filter(|value| !value.is_empty())
722        .collect::<Vec<_>>()
723        .join(", ");
724    if joined.is_empty() {
725        fallback.to_string()
726    } else {
727        joined
728    }
729}
730
731fn dashboard_link(url: Option<&str>, label: &str) -> String {
732    match url.map(str::trim).filter(|value| !value.is_empty()) {
733        Some(url) if url.starts_with("http://") || url.starts_with("https://") => {
734            format!("[{label}]({url})")
735        }
736        Some(value) => value.to_string(),
737        None => "pending".to_string(),
738    }
739}
740
741fn default_heading(profile: RecordProfile, kind: LifecycleCommentKind) -> &'static str {
742    match kind {
743        LifecycleCommentKind::Source => "Source Snapshot",
744        LifecycleCommentKind::Plan => "Plan Snapshot",
745        LifecycleCommentKind::State => "Execution State",
746        LifecycleCommentKind::Session => "Execution Session",
747        LifecycleCommentKind::Validation => "Validation Evidence",
748        LifecycleCommentKind::Review => "Review Evidence",
749        LifecycleCommentKind::Closeout => match profile {
750            RecordProfile::Tracking => "Tracking Issue Closeout",
751            RecordProfile::Dispatch => "Dispatch Issue Closeout",
752        },
753    }
754}
755
756fn default_details_summary(kind: LifecycleCommentKind) -> &'static str {
757    match kind {
758        LifecycleCommentKind::Source => "Source snapshot",
759        LifecycleCommentKind::Plan => "Plan snapshot",
760        _ => "Details",
761    }
762}
763
764/// Render the canonical v2 marker for a lifecycle comment kind.
765fn marker_for(profile: RecordProfile, kind: LifecycleCommentKind) -> String {
766    format!(
767        "<!-- plan-issue-record:v2 role={} profile={} -->",
768        kind.as_str(),
769        profile.as_str()
770    )
771}
772
773fn parse_comments_json(raw: &str) -> Result<Vec<CommentJson>, String> {
774    let value = serde_json::from_str::<Value>(raw)
775        .map_err(|err| format!("failed to parse comments JSON: {err}"))?;
776    let comments_value = match value {
777        Value::Object(mut object) => object
778            .remove("comments")
779            .ok_or_else(|| "comments JSON object is missing `comments`".to_string())?,
780        Value::Array(items) => Value::Array(items),
781        _ => {
782            return Err(
783                "comments JSON must be an array or an object with a `comments` array".to_string(),
784            );
785        }
786    };
787    let Value::Array(items) = comments_value else {
788        return Err("`comments` must be an array".to_string());
789    };
790
791    Ok(items
792        .into_iter()
793        .filter_map(|item| {
794            let Value::Object(mut object) = item else {
795                return None;
796            };
797            Some(CommentJson {
798                body: string_field(&mut object, "body"),
799                url: string_field(&mut object, "url"),
800                html_url: string_field(&mut object, "html_url"),
801                created_at: string_field(&mut object, "created_at")
802                    .or_else(|| string_field(&mut object, "createdAt")),
803            })
804        })
805        .collect())
806}
807
808fn string_field(object: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
809    object
810        .remove(key)
811        .and_then(|value| value.as_str().map(ToString::to_string))
812}
813
814fn first_comment_marker(body: &str) -> Option<&str> {
815    for line in body.lines() {
816        let trimmed = line.trim();
817        if trimmed.is_empty() {
818            continue;
819        }
820        if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
821            return Some(trimmed);
822        }
823        return None;
824    }
825    None
826}
827
828/// Outcome of marker parsing on a comment's first non-empty line.
829#[derive(Debug, Clone)]
830enum MarkerParse {
831    /// Canonical v2 marker.
832    V2 {
833        role: PayloadRole,
834        profile: PayloadProfile,
835    },
836    /// Pre-v2 marker family the v3 lifecycle no longer recognizes as
837    /// current lifecycle evidence (but tracks for reporting).
838    Unsupported { prefix: String },
839}
840
841fn parse_marker_line(marker: &str) -> Option<MarkerParse> {
842    let inner = marker.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
843    let attrs = parse_attrs(inner);
844
845    if let Some(rest) = inner.strip_prefix("plan-issue-record:v2") {
846        let _ = rest;
847        let role = attrs.get("role").and_then(|value| parse_role(value))?;
848        let profile = attrs
849            .get("profile")
850            .and_then(|value| parse_profile(value))
851            .unwrap_or(PayloadProfile::Tracking);
852        return Some(MarkerParse::V2 { role, profile });
853    }
854
855    // Known pre-v2 marker families. They are no longer recognized as
856    // current lifecycle evidence but are reported by audit so callers
857    // can identify v1-marker comments that need migration to v2.
858    for prefix in [
859        "issue-backed-plan:",
860        "plan-tracking-issue:",
861        "execute-from-tracking-issue:",
862        "execute-plan-tracking-issue:",
863        "tracking-issue-closeout:",
864        "plan-tracking-issue-closeout:",
865        "deliver-dispatch-plan:",
866        "dispatch-plan:",
867    ] {
868        if inner.starts_with(prefix) {
869            return Some(MarkerParse::Unsupported {
870                prefix: prefix.trim_end_matches(':').to_string(),
871            });
872        }
873    }
874
875    None
876}
877
878fn parse_role(value: &str) -> Option<PayloadRole> {
879    Some(match value {
880        "source" => PayloadRole::Source,
881        "plan" => PayloadRole::Plan,
882        "state" => PayloadRole::State,
883        "session" => PayloadRole::Session,
884        "validation" => PayloadRole::Validation,
885        "review" => PayloadRole::Review,
886        "closeout" => PayloadRole::Closeout,
887        _ => return None,
888    })
889}
890
891fn parse_profile(value: &str) -> Option<PayloadProfile> {
892    Some(match value {
893        "tracking" => PayloadProfile::Tracking,
894        "dispatch" => PayloadProfile::Dispatch,
895        _ => return None,
896    })
897}
898
899fn parse_attrs(marker: &str) -> BTreeMap<String, String> {
900    let mut attrs = BTreeMap::new();
901    for token in marker.split_whitespace().skip(1) {
902        let Some((key, value)) = token.split_once('=') else {
903            continue;
904        };
905        attrs.insert(
906            key.trim().to_string(),
907            value
908                .trim()
909                .trim_matches('"')
910                .trim_matches('\'')
911                .to_string(),
912        );
913    }
914    attrs
915}
916
917fn inspect_body_sections(body: &str) -> BodySections {
918    BodySections {
919        current_dashboard: body.contains("## Current Dashboard"),
920        final_dashboard: body.contains("## Final Dashboard"),
921        durable_record: body.contains("## Durable Record"),
922        closeout_checks: body.contains("## Closeout Checks"),
923        task_decomposition: body.contains("## Task Decomposition"),
924    }
925}
926
927fn finalize_markdown(lines: Vec<String>) -> String {
928    let mut rendered = lines.join("\n");
929    if !rendered.ends_with('\n') {
930        rendered.push('\n');
931    }
932    rendered
933}
934
935// -----------------------------------------------------------------------------
936// Structured lifecycle payload (issue-backed plan record contract v2)
937//
938// Each lifecycle comment carries one hidden payload carrier. Audit, dashboard
939// repair, and closeout gate evaluation consume the structured payload
940// exclusively. Visible Markdown around the carrier is human commentary only.
941// The older PAYLOAD_FENCE_INFO fenced block remains accepted for existing
942// records created before the hidden carrier renderer.
943// -----------------------------------------------------------------------------
944
945/// On-wire schema identity for v2 lifecycle payloads.
946///
947/// This is the active schema identity for today's lifecycle comments, not a
948/// promise that future state payload replacements keep v2 readable forever.
949pub const PAYLOAD_SCHEMA_V2: &str = "plan-issue-record.payload.v2";
950
951/// Older fenced-code-block info-string used to mark a lifecycle payload.
952pub const PAYLOAD_FENCE_INFO: &str = "plan-issue-record-payload";
953const PAYLOAD_COMMENT_PREFIX: &str = "plan-issue-record-payload:hex:";
954
955#[derive(
956    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, serde::Deserialize,
957)]
958#[serde(rename_all = "lowercase")]
959pub enum PayloadRole {
960    Source,
961    Plan,
962    State,
963    Session,
964    Validation,
965    Review,
966    Closeout,
967}
968
969impl PayloadRole {
970    pub fn as_str(self) -> &'static str {
971        match self {
972            Self::Source => "source",
973            Self::Plan => "plan",
974            Self::State => "state",
975            Self::Session => "session",
976            Self::Validation => "validation",
977            Self::Review => "review",
978            Self::Closeout => "closeout",
979        }
980    }
981}
982
983#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
984#[serde(rename_all = "lowercase")]
985pub enum PayloadProfile {
986    Tracking,
987    Dispatch,
988}
989
990impl PayloadProfile {
991    pub fn as_str(self) -> &'static str {
992        match self {
993            Self::Tracking => "tracking",
994            Self::Dispatch => "dispatch",
995        }
996    }
997}
998
999impl From<RecordProfile> for PayloadProfile {
1000    fn from(value: RecordProfile) -> Self {
1001        match value {
1002            RecordProfile::Tracking => PayloadProfile::Tracking,
1003            RecordProfile::Dispatch => PayloadProfile::Dispatch,
1004        }
1005    }
1006}
1007
1008/// Envelope for every lifecycle comment payload.
1009#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1010pub struct RecordPayload {
1011    pub schema: String,
1012    pub role: PayloadRole,
1013    pub profile: PayloadProfile,
1014    #[serde(default)]
1015    pub updated_at: Option<String>,
1016    #[serde(default)]
1017    pub data: Value,
1018}
1019
1020#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1021pub struct SnapshotData {
1022    pub path: String,
1023    pub commit: String,
1024    #[serde(default)]
1025    pub title: Option<String>,
1026    #[serde(default)]
1027    pub summary: Option<String>,
1028}
1029
1030#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1031#[serde(rename_all = "kebab-case")]
1032pub enum StateStatus {
1033    InProgress,
1034    Complete,
1035    Blocked,
1036}
1037
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1039#[serde(rename_all = "kebab-case")]
1040pub enum TaskRowStatus {
1041    Pending,
1042    InProgress,
1043    Done,
1044    Deferred,
1045    Blocked,
1046    Waived,
1047}
1048
1049#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1050pub struct TaskRowPayload {
1051    pub id: String,
1052    pub status: TaskRowStatus,
1053    #[serde(default)]
1054    pub title: Option<String>,
1055}
1056
1057#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1058#[serde(rename_all = "lowercase")]
1059pub enum PrLifecycleStatus {
1060    Open,
1061    Merged,
1062    Closed,
1063}
1064
1065#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1066pub struct PrRefPayload {
1067    #[serde(rename = "ref")]
1068    pub pr_ref: String,
1069    #[serde(default)]
1070    pub url: Option<String>,
1071    pub status: PrLifecycleStatus,
1072}
1073
1074#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
1075pub struct StateData {
1076    pub status: Option<StateStatus>,
1077    #[serde(default)]
1078    pub target_scope: Option<String>,
1079    #[serde(default)]
1080    pub current: Option<String>,
1081    #[serde(default)]
1082    pub next_action: Option<String>,
1083    #[serde(default)]
1084    pub tasks: Vec<TaskRowPayload>,
1085    #[serde(default)]
1086    pub prs: Vec<PrRefPayload>,
1087    #[serde(default)]
1088    pub blockers: Vec<String>,
1089    #[serde(default)]
1090    pub links: BTreeMap<String, String>,
1091}
1092
1093#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1094pub struct SessionData {
1095    pub summary: String,
1096    #[serde(default)]
1097    pub highlights: Vec<String>,
1098    #[serde(default)]
1099    pub links: BTreeMap<String, String>,
1100}
1101
1102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1103#[serde(rename_all = "lowercase")]
1104pub enum ValidationOverall {
1105    Pass,
1106    Fail,
1107    Partial,
1108}
1109
1110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1111#[serde(rename_all = "lowercase")]
1112pub enum ValidationCommandStatus {
1113    Pass,
1114    Fail,
1115    Skipped,
1116}
1117
1118#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1119pub struct ValidationCommand {
1120    pub command: String,
1121    pub status: ValidationCommandStatus,
1122    #[serde(default)]
1123    pub evidence: Option<String>,
1124}
1125
1126#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1127pub struct ValidationWaiver {
1128    pub command: String,
1129    pub reason: String,
1130}
1131
1132#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1133pub struct ValidationData {
1134    pub overall: ValidationOverall,
1135    #[serde(default)]
1136    pub commands: Vec<ValidationCommand>,
1137    #[serde(default)]
1138    pub waivers: Vec<ValidationWaiver>,
1139}
1140
1141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1142#[serde(rename_all = "kebab-case")]
1143pub enum ReviewDecision {
1144    Approve,
1145    RequestChanges,
1146    CommentsOnly,
1147}
1148
1149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1150#[serde(rename_all = "lowercase")]
1151pub enum FindingSeverity {
1152    Blocker,
1153    Major,
1154    Minor,
1155    Nit,
1156}
1157
1158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1159#[serde(rename_all = "kebab-case")]
1160pub enum FindingDisposition {
1161    Fixed,
1162    Residual,
1163    FollowUp,
1164    Deferred,
1165    NoAction,
1166}
1167
1168#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1169pub struct ReviewFinding {
1170    pub id: String,
1171    pub severity: FindingSeverity,
1172    pub disposition: FindingDisposition,
1173    pub summary: String,
1174}
1175
1176#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1177pub struct ReviewData {
1178    pub decision: ReviewDecision,
1179    #[serde(default)]
1180    pub lenses: Vec<String>,
1181    #[serde(default)]
1182    pub findings: Vec<ReviewFinding>,
1183    #[serde(default)]
1184    pub outcome_comment_url: Option<String>,
1185}
1186
1187#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1188pub struct ApprovalEvidence {
1189    #[serde(default)]
1190    pub comment_url: Option<String>,
1191    #[serde(default)]
1192    pub approver: Option<String>,
1193}
1194
1195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1196#[serde(rename_all = "lowercase")]
1197pub enum CheckStatus {
1198    Pass,
1199    Fail,
1200    None,
1201}
1202
1203#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1204pub struct LinkedPrEvidence {
1205    #[serde(rename = "ref")]
1206    pub pr_ref: String,
1207    #[serde(default)]
1208    pub url: Option<String>,
1209    #[serde(default)]
1210    pub merge_sha: Option<String>,
1211    /// Aggregate rollup over every PR check (required and non-required
1212    /// combined). Kept for backward compatibility with the closeout
1213    /// payload schema; the close gate now consults `required_state`
1214    /// first and only falls back to `checks` when required-check state
1215    /// is unknown.
1216    pub checks: CheckStatus,
1217    /// Required-check rollup when the provider exposes the
1218    /// required/non-required distinction. `None` means the adapter
1219    /// could not resolve a required-only summary (e.g. GitLab today,
1220    /// or a degraded `gh` call), in which case the gate falls back to
1221    /// the aggregate `checks` value.
1222    #[serde(default)]
1223    pub required_state: Option<CheckStatus>,
1224    /// Number of required checks reported by the provider. `None`
1225    /// when required-check classification is unavailable; `Some(0)`
1226    /// means the PR has zero required checks.
1227    #[serde(default)]
1228    pub required_count: Option<u32>,
1229    /// Names of non-required checks that ended in a failure-class
1230    /// state. Surfaced as informational evidence in the closeout
1231    /// comment; never blocks the gate on its own.
1232    #[serde(default)]
1233    pub non_required_failures: Vec<String>,
1234}
1235
1236#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1237pub struct CloseoutData {
1238    pub final_status: String,
1239    pub approval: ApprovalEvidence,
1240    #[serde(default)]
1241    pub linked_prs: Vec<LinkedPrEvidence>,
1242    #[serde(default)]
1243    pub non_required_check_override: Option<Value>,
1244    #[serde(default)]
1245    pub final_validation_url: Option<String>,
1246    #[serde(default)]
1247    pub notes: Option<String>,
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq)]
1251pub enum PayloadErrorKind {
1252    NoFence,
1253    MultipleFences,
1254    SchemaMismatch,
1255    InvalidJson,
1256}
1257
1258#[derive(Debug, Clone)]
1259pub struct PayloadError {
1260    pub kind: PayloadErrorKind,
1261    pub message: String,
1262}
1263
1264impl PayloadError {
1265    fn new(kind: PayloadErrorKind, message: impl Into<String>) -> Self {
1266        Self {
1267            kind,
1268            message: message.into(),
1269        }
1270    }
1271}
1272
1273impl std::fmt::Display for PayloadError {
1274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1275        write!(f, "{}", self.message)
1276    }
1277}
1278
1279impl std::error::Error for PayloadError {}
1280
1281/// Extract the structured lifecycle payload carried inside `comment_body`.
1282///
1283/// - Returns `Ok(payload)` on a single well-formed hidden carrier or older
1284///   `plan-issue-record-payload` fence whose envelope `schema` matches
1285///   [`PAYLOAD_SCHEMA_V2`]. The older fence support is carrier-level support
1286///   for current v2 records; it is not a future old-schema reader contract.
1287/// - Returns `Err(NoFence)` when the comment does not contain either payload
1288///   carrier.
1289/// - Returns `Err(MultipleFences)` when multiple payload carriers are present;
1290///   each comment carries at most one payload.
1291/// - Returns `Err(SchemaMismatch)` when the payload parses but its `schema`
1292///   does not match the v2 wire identity.
1293/// - Returns `Err(InvalidJson)` when the payload body is not valid JSON or
1294///   does not deserialize into the envelope.
1295pub fn extract_payload(comment_body: &str) -> Result<RecordPayload, PayloadError> {
1296    let carriers = collect_payload_comment_carriers(comment_body)?;
1297    let fences = collect_payload_fences(comment_body);
1298    let payload_count = carriers.len() + fences.len();
1299    if payload_count == 0 {
1300        return Err(PayloadError::new(
1301            PayloadErrorKind::NoFence,
1302            "no plan-issue-record-payload carrier or fence in comment body",
1303        ));
1304    }
1305    if payload_count > 1 {
1306        return Err(PayloadError::new(
1307            PayloadErrorKind::MultipleFences,
1308            "multiple plan-issue-record-payload carriers or fences in comment body",
1309        ));
1310    }
1311
1312    let raw = carriers.first().or_else(|| fences.first()).ok_or_else(|| {
1313        PayloadError::new(
1314            PayloadErrorKind::NoFence,
1315            "no plan-issue-record-payload carrier or fence in comment body",
1316        )
1317    })?;
1318    let payload: RecordPayload = serde_json::from_str(raw)
1319        .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))?;
1320    if payload.schema != PAYLOAD_SCHEMA_V2 {
1321        return Err(PayloadError::new(
1322            PayloadErrorKind::SchemaMismatch,
1323            format!(
1324                "expected schema `{PAYLOAD_SCHEMA_V2}`, got `{}`",
1325                payload.schema
1326            ),
1327        ));
1328    }
1329    Ok(payload)
1330}
1331
1332fn collect_payload_comment_carriers(body: &str) -> Result<Vec<String>, PayloadError> {
1333    let mut out = Vec::new();
1334    let mut details_depth = 0usize;
1335    for line in body.lines() {
1336        if update_details_depth(line, &mut details_depth) {
1337            continue;
1338        }
1339        if details_depth > 0 {
1340            continue;
1341        }
1342        let trimmed = line.trim();
1343        let Some(inner) = trimmed
1344            .strip_prefix("<!--")
1345            .and_then(|value| value.strip_suffix("-->"))
1346        else {
1347            continue;
1348        };
1349        let inner = inner.trim();
1350        let Some(encoded) = inner.strip_prefix(PAYLOAD_COMMENT_PREFIX) else {
1351            continue;
1352        };
1353        let payload = decode_hex(encoded.trim()).map_err(|err| {
1354            PayloadError::new(
1355                PayloadErrorKind::InvalidJson,
1356                format!("invalid hidden payload carrier: {err}"),
1357            )
1358        })?;
1359        let payload = String::from_utf8(payload).map_err(|err| {
1360            PayloadError::new(
1361                PayloadErrorKind::InvalidJson,
1362                format!("hidden payload carrier is not UTF-8: {err}"),
1363            )
1364        })?;
1365        out.push(payload);
1366    }
1367    Ok(out)
1368}
1369
1370fn collect_payload_fences(body: &str) -> Vec<String> {
1371    let mut out = Vec::new();
1372    let mut current: Option<Vec<String>> = None;
1373    let mut details_depth = 0usize;
1374    for line in body.lines() {
1375        let trimmed = line.trim_start();
1376        if let Some(buf) = current.as_mut() {
1377            if trimmed.starts_with("```") {
1378                let mut block = String::new();
1379                for chunk in buf.iter() {
1380                    if !block.is_empty() {
1381                        block.push('\n');
1382                    }
1383                    block.push_str(chunk);
1384                }
1385                out.push(block);
1386                current = None;
1387            } else {
1388                buf.push(line.to_string());
1389            }
1390        } else {
1391            if update_details_depth(line, &mut details_depth) {
1392                continue;
1393            }
1394            if details_depth > 0 {
1395                continue;
1396            }
1397            if let Some(rest) = trimmed.strip_prefix("```")
1398                && rest.trim() == PAYLOAD_FENCE_INFO
1399            {
1400                current = Some(Vec::new());
1401            }
1402        }
1403    }
1404    out
1405}
1406
1407fn update_details_depth(line: &str, depth: &mut usize) -> bool {
1408    let trimmed = line.trim();
1409    if trimmed.starts_with("<details") {
1410        *depth += 1;
1411        return true;
1412    }
1413    if trimmed.starts_with("</details>") {
1414        *depth = depth.saturating_sub(1);
1415        return true;
1416    }
1417    false
1418}
1419
1420fn encode_hex(bytes: &[u8]) -> String {
1421    const HEX: &[u8; 16] = b"0123456789abcdef";
1422    let mut out = String::with_capacity(bytes.len() * 2);
1423    for byte in bytes {
1424        out.push(HEX[(byte >> 4) as usize] as char);
1425        out.push(HEX[(byte & 0x0f) as usize] as char);
1426    }
1427    out
1428}
1429
1430fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
1431    if !input.len().is_multiple_of(2) {
1432        return Err("hex payload has odd length".to_string());
1433    }
1434    let mut out = Vec::with_capacity(input.len() / 2);
1435    let bytes = input.as_bytes();
1436    for pair in bytes.chunks_exact(2) {
1437        let hi = hex_value(pair[0])
1438            .ok_or_else(|| format!("invalid hex digit `{}`", char::from(pair[0])))?;
1439        let lo = hex_value(pair[1])
1440            .ok_or_else(|| format!("invalid hex digit `{}`", char::from(pair[1])))?;
1441        out.push((hi << 4) | lo);
1442    }
1443    Ok(out)
1444}
1445
1446fn hex_value(byte: u8) -> Option<u8> {
1447    match byte {
1448        b'0'..=b'9' => Some(byte - b'0'),
1449        b'a'..=b'f' => Some(byte - b'a' + 10),
1450        b'A'..=b'F' => Some(byte - b'A' + 10),
1451        _ => None,
1452    }
1453}
1454
1455impl RecordPayload {
1456    pub fn parse_state(&self) -> Result<StateData, PayloadError> {
1457        self.decode_data(PayloadRole::State)
1458    }
1459
1460    pub fn parse_session(&self) -> Result<SessionData, PayloadError> {
1461        self.decode_data(PayloadRole::Session)
1462    }
1463
1464    pub fn parse_validation(&self) -> Result<ValidationData, PayloadError> {
1465        self.decode_data(PayloadRole::Validation)
1466    }
1467
1468    pub fn parse_review(&self) -> Result<ReviewData, PayloadError> {
1469        self.decode_data(PayloadRole::Review)
1470    }
1471
1472    pub fn parse_closeout(&self) -> Result<CloseoutData, PayloadError> {
1473        self.decode_data(PayloadRole::Closeout)
1474    }
1475
1476    pub fn parse_snapshot(&self) -> Result<SnapshotData, PayloadError> {
1477        if !matches!(self.role, PayloadRole::Source | PayloadRole::Plan) {
1478            return Err(PayloadError::new(
1479                PayloadErrorKind::SchemaMismatch,
1480                format!(
1481                    "expected source or plan payload, got `{}`",
1482                    self.role.as_str()
1483                ),
1484            ));
1485        }
1486        serde_json::from_value::<SnapshotData>(self.data.clone())
1487            .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))
1488    }
1489
1490    fn decode_data<T: serde::de::DeserializeOwned>(
1491        &self,
1492        expected: PayloadRole,
1493    ) -> Result<T, PayloadError> {
1494        if self.role != expected {
1495            return Err(PayloadError::new(
1496                PayloadErrorKind::SchemaMismatch,
1497                format!(
1498                    "expected role `{}`, got `{}`",
1499                    expected.as_str(),
1500                    self.role.as_str()
1501                ),
1502            ));
1503        }
1504        serde_json::from_value::<T>(self.data.clone())
1505            .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))
1506    }
1507}
1508
1509pub fn validate_payload_data_for_kind(
1510    kind: LifecycleCommentKind,
1511    data: &Value,
1512) -> Result<(), PayloadError> {
1513    validate_payload_data_for_role(payload_role_for_kind(kind), data)
1514}
1515
1516fn validate_payload_data_for_role(role: PayloadRole, data: &Value) -> Result<(), PayloadError> {
1517    let payload = RecordPayload {
1518        schema: PAYLOAD_SCHEMA_V2.to_string(),
1519        role,
1520        profile: PayloadProfile::Tracking,
1521        updated_at: None,
1522        data: data.clone(),
1523    };
1524    validate_payload_data(&payload)
1525}
1526
1527fn validate_payload_data(payload: &RecordPayload) -> Result<(), PayloadError> {
1528    match payload.role {
1529        PayloadRole::Source | PayloadRole::Plan => payload.parse_snapshot().map(|_| ()),
1530        PayloadRole::State => payload.parse_state().map(|_| ()),
1531        PayloadRole::Session => payload.parse_session().map(|_| ()),
1532        PayloadRole::Validation => payload.parse_validation().map(|_| ()),
1533        PayloadRole::Review => payload.parse_review().map(|_| ()),
1534        PayloadRole::Closeout => payload.parse_closeout().map(|_| ()),
1535    }
1536}
1537
1538// -----------------------------------------------------------------------------
1539// v2 provider-backed renderers (Sprint 3)
1540//
1541// `render_record_snapshot_comment` and `render_record_post_comment` produce the
1542// canonical Markdown body for `record open` and `record post`: every comment
1543// carries the v2 marker on its first line plus a hidden payload carrier as the
1544// structured source of truth. Audit still accepts the older visible payload
1545// fence for records created before this renderer was fixed.
1546// -----------------------------------------------------------------------------
1547
1548fn payload_role_for_kind(kind: LifecycleCommentKind) -> PayloadRole {
1549    match kind {
1550        LifecycleCommentKind::Source => PayloadRole::Source,
1551        LifecycleCommentKind::Plan => PayloadRole::Plan,
1552        LifecycleCommentKind::State => PayloadRole::State,
1553        LifecycleCommentKind::Session => PayloadRole::Session,
1554        LifecycleCommentKind::Validation => PayloadRole::Validation,
1555        LifecycleCommentKind::Review => PayloadRole::Review,
1556        LifecycleCommentKind::Closeout => PayloadRole::Closeout,
1557    }
1558}
1559
1560fn render_payload_carrier(envelope: &RecordPayload) -> Result<String, String> {
1561    let envelope_json = serde_json::to_string(envelope).map_err(|err| err.to_string())?;
1562    Ok(format!(
1563        "<!-- {PAYLOAD_COMMENT_PREFIX}{} -->",
1564        encode_hex(envelope_json.as_bytes())
1565    ))
1566}
1567
1568/// Render the canonical v2 source/plan snapshot comment used by
1569/// `record open`. The body carries the v2 marker, visible details, and a
1570/// hidden structured payload carrying [`SnapshotData`].
1571pub fn render_record_snapshot_comment(
1572    profile: RecordProfile,
1573    kind: LifecycleCommentKind,
1574    snapshot: &SnapshotData,
1575    content: &str,
1576    updated_at: Option<&str>,
1577) -> Result<String, String> {
1578    if !matches!(
1579        kind,
1580        LifecycleCommentKind::Source | LifecycleCommentKind::Plan
1581    ) {
1582        return Err(format!(
1583            "render_record_snapshot_comment: expected source or plan kind, got `{}`",
1584            kind.as_str()
1585        ));
1586    }
1587
1588    let envelope = RecordPayload {
1589        schema: PAYLOAD_SCHEMA_V2.to_string(),
1590        role: payload_role_for_kind(kind),
1591        profile: PayloadProfile::from(profile),
1592        updated_at: updated_at.map(str::to_string),
1593        data: serde_json::to_value(snapshot).map_err(|err| err.to_string())?,
1594    };
1595    let envelope_carrier = render_payload_carrier(&envelope)?;
1596
1597    let path = Some(snapshot.path.trim()).filter(|value| !value.is_empty());
1598    let commit = Some(snapshot.commit.trim()).filter(|value| !value.is_empty());
1599    let summary = snapshot
1600        .summary
1601        .as_deref()
1602        .map(str::trim)
1603        .filter(|value| !value.is_empty());
1604
1605    let view = SnapshotView {
1606        marker: marker_for(profile, kind),
1607        heading: default_heading(profile, kind),
1608        profile: profile.as_str(),
1609        path,
1610        commit,
1611        summary,
1612        details_summary: default_details_summary(kind),
1613        content,
1614        envelope_carrier,
1615    };
1616
1617    let mut engine = Engine::builder().build();
1618    engine
1619        .register_template(SNAPSHOT_TEMPLATE_NAME, SNAPSHOT_TEMPLATE)
1620        .map_err(|err| format!("snapshot template register failed: {err}"))?;
1621    engine
1622        .render(SNAPSHOT_TEMPLATE_NAME, &view)
1623        .map_err(|err| format!("snapshot template render failed: {err}"))
1624}
1625
1626/// Extract the verbatim file content embedded in a `source`/`plan` snapshot
1627/// comment's `<details>` block. This is the inverse of the content placement
1628/// in [`render_record_snapshot_comment`]: that renderer emits
1629/// `<details>`/`<summary>…</summary>` then a blank line, the file content
1630/// verbatim, a blank line, and `</details>` (see `snapshot.md.tera`). This
1631/// returns the lines between the blank line after `<summary>` and the
1632/// snapshot wrapper's matching `</details>`, accounting for any `<details>`
1633/// blocks nested inside the content itself.
1634///
1635/// The renderer surrounds the content with structural blank lines, so the
1636/// trailing blank padding is dropped and the result is normalized to end with
1637/// exactly one `\n`. Bundle Markdown files conventionally end with a single
1638/// trailing newline, so this round-trips byte-for-byte for well-formed files.
1639pub fn extract_snapshot_content(comment_body: &str) -> Result<String, String> {
1640    let lines: Vec<&str> = comment_body.lines().collect();
1641    let open_idx = lines
1642        .iter()
1643        .position(|line| line.trim().starts_with("<details"))
1644        .ok_or_else(|| "snapshot comment has no <details> block".to_string())?;
1645
1646    // The wrapper opener is followed by `<summary>…</summary>` and one blank
1647    // line of template padding before the content begins.
1648    let mut start = open_idx + 1;
1649    if start < lines.len() && lines[start].trim_start().starts_with("<summary") {
1650        start += 1;
1651    }
1652    if start < lines.len() && lines[start].trim().is_empty() {
1653        start += 1;
1654    }
1655
1656    // Collect content until the wrapper's matching `</details>`, tracking
1657    // nested `<details>` blocks that may appear inside the file content.
1658    let mut depth = 1usize;
1659    let mut content: Vec<&str> = Vec::new();
1660    let mut closed = false;
1661    for line in &lines[start..] {
1662        let trimmed = line.trim();
1663        if trimmed.starts_with("<details") {
1664            depth += 1;
1665        } else if trimmed.starts_with("</details>") {
1666            depth -= 1;
1667            if depth == 0 {
1668                closed = true;
1669                break;
1670            }
1671        }
1672        content.push(line);
1673    }
1674    if !closed {
1675        return Err("snapshot <details> block is not closed".to_string());
1676    }
1677
1678    // Drop the renderer's trailing blank-line padding, then normalize to a
1679    // single trailing newline.
1680    while content.last().is_some_and(|line| line.trim().is_empty()) {
1681        content.pop();
1682    }
1683    let mut out = content.join("\n");
1684    out.push('\n');
1685    Ok(out)
1686}
1687
1688/// Render the canonical v2 lifecycle comment used by `record post` for
1689/// state, session, validation, review, and closeout kinds. Source/plan
1690/// kinds are rejected because `record open` owns them.
1691pub fn render_record_post_comment(
1692    profile: RecordProfile,
1693    kind: LifecycleCommentKind,
1694    payload_data: Value,
1695    summary: Option<&str>,
1696    updated_at: Option<&str>,
1697) -> Result<String, String> {
1698    render_record_post_comment_with_display(
1699        profile,
1700        kind,
1701        payload_data,
1702        summary,
1703        updated_at,
1704        TaskLedgerDisplay::Auto,
1705    )
1706}
1707
1708/// Controls how the visible Execution State header is produced.
1709#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1710pub enum StateHeaderMode {
1711    /// Preserve the authored execution-state header verbatim. Used by
1712    /// `record open` and `record post`, where the caller supplies the canonical
1713    /// execution-state markdown and expects its metadata bullets preserved.
1714    Authored,
1715    /// Re-render the header (`Status` / `Target scope` / `Current task` /
1716    /// `Next task`) from the derived payload. Used by `tracking checkpoint`,
1717    /// where the controller owns deriving live state from run-state so a
1718    /// completed plan never keeps a frozen pre-flight header
1719    /// (graysurf/plan-tracking-testbed#54 / sympoies/nils-cli#700).
1720    DeriveFromPayload,
1721}
1722
1723pub fn render_record_post_comment_with_display(
1724    profile: RecordProfile,
1725    kind: LifecycleCommentKind,
1726    payload_data: Value,
1727    summary: Option<&str>,
1728    updated_at: Option<&str>,
1729    task_ledger_display: TaskLedgerDisplay,
1730) -> Result<String, String> {
1731    render_record_post_comment_with_display_mode(
1732        profile,
1733        kind,
1734        payload_data,
1735        summary,
1736        updated_at,
1737        task_ledger_display,
1738        StateHeaderMode::Authored,
1739    )
1740}
1741
1742pub fn render_record_post_comment_with_display_mode(
1743    profile: RecordProfile,
1744    kind: LifecycleCommentKind,
1745    payload_data: Value,
1746    summary: Option<&str>,
1747    updated_at: Option<&str>,
1748    task_ledger_display: TaskLedgerDisplay,
1749    header_mode: StateHeaderMode,
1750) -> Result<String, String> {
1751    if matches!(
1752        kind,
1753        LifecycleCommentKind::Source | LifecycleCommentKind::Plan
1754    ) {
1755        return Err(format!(
1756            "render_record_post_comment: source/plan kinds are owned by `record open`, got `{}`",
1757            kind.as_str()
1758        ));
1759    }
1760
1761    let visible_content = render_visible_post_content(
1762        kind,
1763        &payload_data,
1764        summary,
1765        task_ledger_display,
1766        header_mode,
1767    )?;
1768    let envelope = RecordPayload {
1769        schema: PAYLOAD_SCHEMA_V2.to_string(),
1770        role: payload_role_for_kind(kind),
1771        profile: PayloadProfile::from(profile),
1772        updated_at: updated_at.map(str::to_string),
1773        data: payload_data,
1774    };
1775    let envelope_carrier = render_payload_carrier(&envelope)?;
1776
1777    let view = PostCommentView {
1778        marker: marker_for(profile, kind),
1779        heading: default_heading(profile, kind),
1780        profile: profile.as_str(),
1781        visible_content,
1782        envelope_carrier,
1783    };
1784
1785    let mut engine = Engine::builder().build();
1786    engine
1787        .register_template(POST_COMMENT_TEMPLATE_NAME, POST_COMMENT_TEMPLATE)
1788        .map_err(|err| format!("post_comment template register failed: {err}"))?;
1789    engine
1790        .render(POST_COMMENT_TEMPLATE_NAME, &view)
1791        .map_err(|err| format!("post_comment template render failed: {err}"))
1792}
1793
1794fn render_visible_post_content(
1795    kind: LifecycleCommentKind,
1796    payload_data: &Value,
1797    summary: Option<&str>,
1798    task_ledger_display: TaskLedgerDisplay,
1799    header_mode: StateHeaderMode,
1800) -> Result<String, String> {
1801    let summary = summary.map(str::trim).filter(|value| !value.is_empty());
1802    let generated = match kind {
1803        LifecycleCommentKind::State => {
1804            let state = serde_json::from_value::<StateData>(payload_data.clone())
1805                .map_err(|err| format!("state payload invalid for visible rendering: {err}"))?;
1806            match summary {
1807                Some(text) if text.contains("## Task Ledger") => {
1808                    render_state_markdown_with_task_ledger_display(
1809                        text,
1810                        task_ledger_display,
1811                        &state,
1812                        header_mode,
1813                    )?
1814                }
1815                Some(text) => text.to_string(),
1816                None => render_state_payload_visible(&state),
1817            }
1818        }
1819        LifecycleCommentKind::Session => {
1820            let session = serde_json::from_value::<SessionData>(payload_data.clone())
1821                .map_err(|err| format!("session payload invalid for visible rendering: {err}"))?;
1822            combine_summary_and_generated(
1823                summary,
1824                render_session_payload_visible(&session, payload_data),
1825            )
1826        }
1827        LifecycleCommentKind::Validation => {
1828            let validation = serde_json::from_value::<ValidationData>(payload_data.clone())
1829                .map_err(|err| {
1830                    format!("validation payload invalid for visible rendering: {err}")
1831                })?;
1832            combine_summary_and_generated(summary, render_validation_payload_visible(&validation))
1833        }
1834        LifecycleCommentKind::Review => {
1835            let review = serde_json::from_value::<ReviewData>(payload_data.clone())
1836                .map_err(|err| format!("review payload invalid for visible rendering: {err}"))?;
1837            combine_summary_and_generated(summary, render_review_payload_visible(&review))
1838        }
1839        LifecycleCommentKind::Closeout => {
1840            let closeout = serde_json::from_value::<CloseoutData>(payload_data.clone())
1841                .map_err(|err| format!("closeout payload invalid for visible rendering: {err}"))?;
1842            combine_summary_and_generated(summary, render_closeout_payload_visible(&closeout))
1843        }
1844        LifecycleCommentKind::Source | LifecycleCommentKind::Plan => unreachable!(),
1845    };
1846
1847    if generated.trim().is_empty() {
1848        return Err(format!(
1849            "`record post --kind {}` would render no visible lifecycle content",
1850            kind.as_str()
1851        ));
1852    }
1853    Ok(generated)
1854}
1855
1856fn combine_summary_and_generated(summary: Option<&str>, generated: String) -> String {
1857    match (summary, generated.trim().is_empty()) {
1858        (Some(text), false) => format!("{}\n\n{}", text.trim(), generated.trim()),
1859        (Some(text), true) => text.trim().to_string(),
1860        (None, _) => generated,
1861    }
1862}
1863
1864fn render_state_markdown_with_task_ledger_display(
1865    markdown: &str,
1866    display: TaskLedgerDisplay,
1867    state: &StateData,
1868    header_mode: StateHeaderMode,
1869) -> Result<String, String> {
1870    let markdown = normalize_state_markdown_for_comment(markdown)?;
1871    // On the `tracking checkpoint` path, re-render the authored header
1872    // (everything before the first `## ` section) from the derived payload so a
1873    // completed plan reflects live progress instead of a frozen pre-flight
1874    // header (graysurf/plan-tracking-testbed#54 / sympoies/nils-cli#700).
1875    // `record open` / `record post` keep the authored header verbatim. Authored
1876    // sections — `## Task Ledger`, `## Validation Plan`, … — are preserved
1877    // either way.
1878    let markdown = match header_mode {
1879        StateHeaderMode::DeriveFromPayload => replace_state_header_from_payload(&markdown, state),
1880        StateHeaderMode::Authored => markdown,
1881    };
1882    let effective = match display {
1883        TaskLedgerDisplay::Expanded => TaskLedgerDisplay::Expanded,
1884        TaskLedgerDisplay::Collapsed => TaskLedgerDisplay::Collapsed,
1885        TaskLedgerDisplay::Open => TaskLedgerDisplay::Open,
1886        TaskLedgerDisplay::Auto => {
1887            if is_terminal_state(state) {
1888                TaskLedgerDisplay::Expanded
1889            } else {
1890                TaskLedgerDisplay::Collapsed
1891            }
1892        }
1893    };
1894    if effective == TaskLedgerDisplay::Expanded {
1895        return Ok(markdown);
1896    }
1897    // `Collapsed` renders a closed fold; `Open` keeps the same fold toggle but
1898    // adds the `open` attribute so the ledger is visible by default.
1899    let details_open_tag = match effective {
1900        TaskLedgerDisplay::Open => "<details open>",
1901        _ => "<details>",
1902    };
1903
1904    let lines: Vec<&str> = markdown.lines().collect();
1905    let Some(start) = lines
1906        .iter()
1907        .position(|line| line.trim() == "## Task Ledger")
1908    else {
1909        return Err("execution-state markdown is missing `## Task Ledger`".to_string());
1910    };
1911    let end = lines
1912        .iter()
1913        .enumerate()
1914        .skip(start + 1)
1915        .find_map(|(idx, line)| {
1916            if line.starts_with("## ") {
1917                Some(idx)
1918            } else {
1919                None
1920            }
1921        })
1922        .unwrap_or(lines.len());
1923    let body = lines[start + 1..end].join("\n").trim().to_string();
1924    if body.is_empty() {
1925        return Err("execution-state Task Ledger section is empty".to_string());
1926    }
1927
1928    let mut out = Vec::new();
1929    out.extend(lines[..=start].iter().map(|line| (*line).to_string()));
1930    out.push(String::new());
1931    out.push(details_open_tag.to_string());
1932    out.push("<summary>Show task ledger</summary>".to_string());
1933    out.push(String::new());
1934    out.push(body);
1935    out.push(String::new());
1936    out.push("</details>".to_string());
1937    if end < lines.len() {
1938        out.push(String::new());
1939        out.extend(lines[end..].iter().map(|line| (*line).to_string()));
1940    }
1941    Ok(finalize_markdown(out).trim().to_string())
1942}
1943
1944fn normalize_state_markdown_for_comment(markdown: &str) -> Result<String, String> {
1945    let stripped = markdown
1946        .trim()
1947        .lines()
1948        .filter(|line| {
1949            let trimmed = line.trim();
1950            !trimmed.starts_with("<!-- plan-issue-record:")
1951                && !trimmed.starts_with("<!-- execute-from-tracking-issue:")
1952        })
1953        .map(str::to_string)
1954        .collect::<Vec<_>>();
1955    let Some(execution_heading) = stripped
1956        .iter()
1957        .position(|line| line.trim() == "## Execution State")
1958    else {
1959        return Err("execution-state markdown is missing `## Execution State`".to_string());
1960    };
1961
1962    let mut out = stripped
1963        .into_iter()
1964        .skip(execution_heading + 1)
1965        .filter(|line| !line.trim().starts_with("- Profile:"))
1966        .collect::<Vec<_>>();
1967    while out.first().is_some_and(|line| line.trim().is_empty()) {
1968        out.remove(0);
1969    }
1970    let normalized = finalize_markdown(out).trim().to_string();
1971    if normalized.is_empty() {
1972        return Err("execution-state markdown has no visible state content".to_string());
1973    }
1974    Ok(normalized)
1975}
1976
1977fn is_terminal_state(state: &StateData) -> bool {
1978    state.status == Some(StateStatus::Complete)
1979        && state.tasks.iter().all(|task| {
1980            matches!(
1981                task.status,
1982                TaskRowStatus::Done | TaskRowStatus::Deferred | TaskRowStatus::Waived
1983            )
1984        })
1985}
1986
1987fn render_state_payload_visible(state: &StateData) -> String {
1988    let view = StateVisibleView {
1989        status: state.status.map(status_state_label),
1990        target_scope: state
1991            .target_scope
1992            .as_deref()
1993            .filter(|value| !value.is_empty()),
1994        current: state.current.as_deref().filter(|value| !value.is_empty()),
1995        next_action: state
1996            .next_action
1997            .as_deref()
1998            .filter(|value| !value.is_empty()),
1999        tasks: state
2000            .tasks
2001            .iter()
2002            .map(|task| StateTaskRow {
2003                id: table_cell(&task.id),
2004                status: task_row_status_label(task.status),
2005                title: table_cell(task.title.as_deref().unwrap_or("")),
2006            })
2007            .collect(),
2008    };
2009    let mut engine = Engine::builder().build();
2010    engine
2011        .register_template(STATE_VISIBLE_TEMPLATE_NAME, STATE_VISIBLE_TEMPLATE)
2012        .expect("state template registers");
2013    let rendered = engine
2014        .render(STATE_VISIBLE_TEMPLATE_NAME, &view)
2015        .expect("state template renders");
2016    rendered.trim().to_string()
2017}
2018
2019/// Rebuild a normalized execution-state body with its header bullets derived
2020/// from the payload, keeping every `## ` section (Task Ledger, Validation Plan,
2021/// …) from the authored markdown. The input must already be normalized (marker
2022/// and `- Profile:` lines stripped, header starting at the top). When the
2023/// payload yields no header bullets the authored body is returned unchanged so
2024/// we never drop all visible content.
2025fn replace_state_header_from_payload(markdown: &str, state: &StateData) -> String {
2026    let header = render_state_header_lines_from_payload(state);
2027    if header.is_empty() {
2028        return markdown.to_string();
2029    }
2030    let lines: Vec<&str> = markdown.lines().collect();
2031    let first_section = lines
2032        .iter()
2033        .position(|line| line.trim_start().starts_with("## "));
2034    let mut out = header;
2035    if let Some(idx) = first_section {
2036        out.push(String::new());
2037        out.extend(lines[idx..].iter().map(|line| (*line).to_string()));
2038    }
2039    finalize_markdown(out).trim().to_string()
2040}
2041
2042/// Render the canonical Execution State header bullets (`Status` / `Target
2043/// scope` / `Current task` / `Next task`) from the payload, omitting any field
2044/// that is absent or empty.
2045fn render_state_header_lines_from_payload(state: &StateData) -> Vec<String> {
2046    let mut lines = Vec::new();
2047    if let Some(status) = state.status.map(status_state_label) {
2048        lines.push(format!("- Status: {status}"));
2049    }
2050    if let Some(scope) = state
2051        .target_scope
2052        .as_deref()
2053        .filter(|value| !value.is_empty())
2054    {
2055        lines.push(format!("- Target scope: {scope}"));
2056    }
2057    if let Some(current) = state.current.as_deref().filter(|value| !value.is_empty()) {
2058        lines.push(format!("- Current task: {current}"));
2059    }
2060    if let Some(next) = state
2061        .next_action
2062        .as_deref()
2063        .filter(|value| !value.is_empty())
2064    {
2065        lines.push(format!("- Next task: {next}"));
2066    }
2067    lines
2068}
2069
2070fn render_session_payload_visible(session: &SessionData, raw: &Value) -> String {
2071    let extras: Vec<KeyValuePair> = raw
2072        .as_object()
2073        .map(|object| {
2074            object
2075                .iter()
2076                .filter(|(key, value)| {
2077                    !matches!(key.as_str(), "summary" | "highlights" | "links") && !value.is_null()
2078                })
2079                .map(|(key, value)| KeyValuePair {
2080                    key: key.trim().to_string(),
2081                    value: visible_value(value),
2082                })
2083                .collect()
2084        })
2085        .unwrap_or_default();
2086
2087    let view = SessionVisibleView {
2088        summary: session.summary.trim(),
2089        highlights: session
2090            .highlights
2091            .iter()
2092            .map(|item| item.trim().to_string())
2093            .collect(),
2094        links: session
2095            .links
2096            .iter()
2097            .map(|(key, value)| KeyValuePair {
2098                key: key.trim().to_string(),
2099                value: value.trim().to_string(),
2100            })
2101            .collect(),
2102        extras,
2103    };
2104    let mut engine = Engine::builder().build();
2105    engine
2106        .register_template(SESSION_VISIBLE_TEMPLATE_NAME, SESSION_VISIBLE_TEMPLATE)
2107        .expect("session template registers");
2108    let rendered = engine
2109        .render(SESSION_VISIBLE_TEMPLATE_NAME, &view)
2110        .expect("session template renders");
2111    rendered.trim().to_string()
2112}
2113
2114fn render_validation_payload_visible(validation: &ValidationData) -> String {
2115    let view = ValidationVisibleView {
2116        overall: validation_overall_label(validation.overall),
2117        commands: validation
2118            .commands
2119            .iter()
2120            .map(|command| ValidationCommandRow {
2121                command: table_cell(&command.command),
2122                status: validation_command_status_label(command.status),
2123                evidence: table_cell(command.evidence.as_deref().unwrap_or("")),
2124                _phantom: std::marker::PhantomData,
2125            })
2126            .collect(),
2127        waivers: validation
2128            .waivers
2129            .iter()
2130            .map(|waiver| ValidationWaiverRow {
2131                command: waiver.command.trim(),
2132                reason: waiver.reason.trim(),
2133            })
2134            .collect(),
2135    };
2136    let mut engine = Engine::builder().build();
2137    engine
2138        .register_template(
2139            VALIDATION_VISIBLE_TEMPLATE_NAME,
2140            VALIDATION_VISIBLE_TEMPLATE,
2141        )
2142        .expect("validation template registers");
2143    let rendered = engine
2144        .render(VALIDATION_VISIBLE_TEMPLATE_NAME, &view)
2145        .expect("validation template renders");
2146    rendered.trim().to_string()
2147}
2148
2149fn render_review_payload_visible(review: &ReviewData) -> String {
2150    let view = ReviewVisibleView {
2151        decision: review_decision_label(review.decision),
2152        lenses: if review.lenses.is_empty() {
2153            None
2154        } else {
2155            Some(review.lenses.join(", "))
2156        },
2157        outcome_comment_url: review
2158            .outcome_comment_url
2159            .as_deref()
2160            .map(str::trim)
2161            .filter(|value| !value.is_empty()),
2162        findings: review
2163            .findings
2164            .iter()
2165            .map(|finding| ReviewFindingRow {
2166                id: table_cell(&finding.id),
2167                severity: finding_severity_label(finding.severity),
2168                disposition: finding_disposition_label(finding.disposition),
2169                summary: table_cell(&finding.summary),
2170            })
2171            .collect(),
2172    };
2173    let mut engine = Engine::builder().build();
2174    engine
2175        .register_template(REVIEW_VISIBLE_TEMPLATE_NAME, REVIEW_VISIBLE_TEMPLATE)
2176        .expect("review template registers");
2177    let rendered = engine
2178        .render(REVIEW_VISIBLE_TEMPLATE_NAME, &view)
2179        .expect("review template renders");
2180    rendered.trim().to_string()
2181}
2182
2183fn render_closeout_payload_visible(closeout: &CloseoutData) -> String {
2184    let override_block = closeout
2185        .non_required_check_override
2186        .as_ref()
2187        .filter(|value| !value.is_null());
2188    let override_reason = override_block.and_then(|block| {
2189        block
2190            .get("reason")
2191            .and_then(Value::as_str)
2192            .map(str::trim)
2193            .filter(|value| !value.is_empty())
2194            .map(str::to_string)
2195    });
2196    let override_failures = override_block.and_then(|block| {
2197        let items = block
2198            .get("observed_non_required_failures")
2199            .and_then(Value::as_array)
2200            .filter(|items| !items.is_empty())?;
2201        Some(
2202            items
2203                .iter()
2204                .map(visible_value)
2205                .filter(|value| !value.trim().is_empty())
2206                .collect::<Vec<_>>()
2207                .join(", "),
2208        )
2209    });
2210    let has_override = override_block.is_some();
2211
2212    let view = CloseoutVisibleView {
2213        final_status: closeout.final_status.trim(),
2214        approver: closeout
2215            .approval
2216            .approver
2217            .as_deref()
2218            .map(str::trim)
2219            .filter(|value| !value.is_empty()),
2220        approval_url: closeout
2221            .approval
2222            .comment_url
2223            .as_deref()
2224            .map(str::trim)
2225            .filter(|value| !value.is_empty()),
2226        final_validation_url: closeout
2227            .final_validation_url
2228            .as_deref()
2229            .map(str::trim)
2230            .filter(|value| !value.is_empty()),
2231        notes: closeout
2232            .notes
2233            .as_deref()
2234            .map(str::trim)
2235            .filter(|value| !value.is_empty()),
2236        has_override,
2237        override_reason,
2238        override_failures,
2239        linked_prs: closeout
2240            .linked_prs
2241            .iter()
2242            .map(|pr| {
2243                let pr_label = pr.url.as_deref().unwrap_or(&pr.pr_ref);
2244                let required_label = required_check_label(pr.required_state, pr.required_count);
2245                CloseoutPrRow {
2246                    label: table_cell(pr_label),
2247                    merge_sha: table_cell(pr.merge_sha.as_deref().unwrap_or("")),
2248                    checks: check_status_label(pr.checks),
2249                    required: table_cell(&required_label),
2250                    non_required_failures: table_cell(&non_empty_join(
2251                        &pr.non_required_failures,
2252                        "none",
2253                    )),
2254                }
2255            })
2256            .collect(),
2257    };
2258    let mut engine = Engine::builder().build();
2259    engine
2260        .register_template(CLOSEOUT_VISIBLE_TEMPLATE_NAME, CLOSEOUT_VISIBLE_TEMPLATE)
2261        .expect("closeout template registers");
2262    let rendered = engine
2263        .render(CLOSEOUT_VISIBLE_TEMPLATE_NAME, &view)
2264        .expect("closeout template renders");
2265    rendered.trim().to_string()
2266}
2267
2268fn task_row_status_label(status: TaskRowStatus) -> &'static str {
2269    match status {
2270        TaskRowStatus::Pending => "pending",
2271        TaskRowStatus::InProgress => "in-progress",
2272        TaskRowStatus::Done => "done",
2273        TaskRowStatus::Deferred => "deferred",
2274        TaskRowStatus::Blocked => "blocked",
2275        TaskRowStatus::Waived => "waived",
2276    }
2277}
2278
2279fn validation_command_status_label(status: ValidationCommandStatus) -> &'static str {
2280    match status {
2281        ValidationCommandStatus::Pass => "pass",
2282        ValidationCommandStatus::Fail => "fail",
2283        ValidationCommandStatus::Skipped => "skipped",
2284    }
2285}
2286
2287fn finding_severity_label(severity: FindingSeverity) -> &'static str {
2288    match severity {
2289        FindingSeverity::Blocker => "blocker",
2290        FindingSeverity::Major => "major",
2291        FindingSeverity::Minor => "minor",
2292        FindingSeverity::Nit => "nit",
2293    }
2294}
2295
2296fn finding_disposition_label(disposition: FindingDisposition) -> &'static str {
2297    match disposition {
2298        FindingDisposition::Fixed => "fixed",
2299        FindingDisposition::Residual => "residual",
2300        FindingDisposition::FollowUp => "follow-up",
2301        FindingDisposition::Deferred => "deferred",
2302        FindingDisposition::NoAction => "no-action",
2303    }
2304}
2305
2306fn check_status_label(status: CheckStatus) -> &'static str {
2307    match status {
2308        CheckStatus::Pass => "pass",
2309        CheckStatus::Fail => "fail",
2310        CheckStatus::None => "none",
2311    }
2312}
2313
2314/// Render the closeout-comment `Required` column from the
2315/// `(required_state, required_count)` pair on a [`LinkedPrEvidence`].
2316///
2317/// Five label branches:
2318///
2319/// - `Some(Pass) + Some(0)` → `"none required"` — no required-check
2320///   rule exists for the branch (or rule explicitly declares zero
2321///   required checks). The earlier rendering collapsed this into
2322///   `"unknown"` even on healthy PRs (sympoies/nils-cli#541 closeout).
2323/// - `Some(Pass) + Some(N>=1)` → `"pass (N)"` — required checks
2324///   enforced and green.
2325/// - `Some(Pass) + None` → `"pass"` — required-state known but count
2326///   not surfaced by the provider; defensive case kept for future
2327///   adapters.
2328/// - `Some(Fail) + …` → `"fail (N)"` or `"fail"` — required checks
2329///   enforced and at least one is red. Non-required failures are
2330///   carried in the adjacent column.
2331/// - `Some(None) + …` → `"none"` — provider reported no aggregate
2332///   rollup at all (e.g. PR #554 on #541's closeout, where GHA never
2333///   registered any check suite).
2334/// - `None + …` → `"unknown"` — adapter probe failed (e.g. `gh` spawn
2335///   error, `gh pr checks --required` non-zero with unrecognised
2336///   stderr, fixture omits the field). Kept as the catch-all so a
2337///   future probe regression remains visible.
2338fn required_check_label(state: Option<CheckStatus>, count: Option<u32>) -> String {
2339    match (state, count) {
2340        (Some(CheckStatus::Pass), Some(0)) => "none required".to_string(),
2341        (Some(CheckStatus::Pass), Some(n)) => format!("pass ({n})"),
2342        (Some(CheckStatus::Pass), None) => "pass".to_string(),
2343        (Some(CheckStatus::Fail), Some(n)) => format!("fail ({n})"),
2344        (Some(CheckStatus::Fail), None) => "fail".to_string(),
2345        (Some(CheckStatus::None), _) => "none".to_string(),
2346        (None, _) => "unknown".to_string(),
2347    }
2348}
2349
2350fn table_cell(value: &str) -> String {
2351    value.trim().replace('|', "\\|").replace('\n', "<br>")
2352}
2353
2354fn visible_value(value: &Value) -> String {
2355    match value {
2356        Value::String(text) => text.trim().to_string(),
2357        Value::Array(items) => items
2358            .iter()
2359            .map(visible_value)
2360            .collect::<Vec<_>>()
2361            .join(", "),
2362        Value::Object(_) => value.to_string(),
2363        Value::Null => String::new(),
2364        _ => value.to_string(),
2365    }
2366}
2367
2368// -----------------------------------------------------------------------------
2369// Strict closeout gate for `record close`.
2370// -----------------------------------------------------------------------------
2371
2372#[derive(Debug, Clone, Serialize)]
2373pub struct StrictCloseoutGateResult {
2374    pub ready: bool,
2375    pub checks: Vec<CloseoutCheck>,
2376    /// Stable machine-readable codes for blocked items, one per failure.
2377    pub blocked_codes: Vec<String>,
2378}
2379
2380#[derive(Debug, Clone)]
2381pub struct StrictCloseoutGateInput<'a> {
2382    pub profile: RecordProfile,
2383    pub approval: Option<&'a str>,
2384    /// Provider-verified linked PR evidence. Each entry must carry a
2385    /// `merge_sha`; missing merge_sha is treated as `linked-pr-not-merged`.
2386    pub linked_prs: &'a [LinkedPrEvidence],
2387    /// Current issue body. When paired with `expected_dashboard`, the gate
2388    /// fails with `dashboard-out-of-date` if the recomputed dashboard does
2389    /// not appear in the body.
2390    pub current_body: Option<&'a str>,
2391    pub expected_dashboard: Option<&'a str>,
2392    /// When `true`, the linked-PR branch skips the conservative
2393    /// "unknown required-check state with aggregate failure" check
2394    /// and lets the gate pass on non-required failures alone. The
2395    /// caller is responsible for surfacing the override decision in
2396    /// closeout-comment evidence; the gate itself does not record it.
2397    pub allow_non_required_check_failure: bool,
2398}
2399
2400pub fn evaluate_strict_closeout_gate(
2401    audit: &RecordAudit,
2402    input: StrictCloseoutGateInput<'_>,
2403) -> StrictCloseoutGateResult {
2404    let mut checks = Vec::new();
2405    let mut blocked_codes: Vec<String> = Vec::new();
2406
2407    let push_pass = |checks: &mut Vec<CloseoutCheck>, check: &str, detail: String| {
2408        checks.push(CloseoutCheck {
2409            check: check.to_string(),
2410            status: "pass".to_string(),
2411            detail,
2412        });
2413    };
2414    let push_fail = |checks: &mut Vec<CloseoutCheck>,
2415                     blocked: &mut Vec<String>,
2416                     check: &str,
2417                     detail: String,
2418                     code: &str| {
2419        checks.push(CloseoutCheck {
2420            check: check.to_string(),
2421            status: "fail".to_string(),
2422            detail,
2423        });
2424        blocked.push(code.to_string());
2425    };
2426
2427    for (role, label, code) in [
2428        ("source", "source snapshot", "source-missing"),
2429        ("plan", "plan snapshot", "plan-missing"),
2430    ] {
2431        if audit.evidence.contains_key(role) {
2432            push_pass(&mut checks, label, "present".to_string());
2433        } else {
2434            push_fail(
2435                &mut checks,
2436                &mut blocked_codes,
2437                label,
2438                "missing".to_string(),
2439                code,
2440            );
2441        }
2442    }
2443
2444    match audit.evidence.get("state") {
2445        Some(hit) => {
2446            let status = hit.status.as_deref();
2447            let parsed = hit
2448                .payload
2449                .as_ref()
2450                .and_then(|payload| payload.parse_state().ok());
2451            match status {
2452                Some(value) if value.eq_ignore_ascii_case("complete") => {
2453                    let tasks_incomplete = parsed
2454                        .as_ref()
2455                        .map(|data| {
2456                            data.tasks.iter().any(|task| {
2457                                !matches!(
2458                                    task.status,
2459                                    TaskRowStatus::Done | TaskRowStatus::Deferred
2460                                )
2461                            })
2462                        })
2463                        .unwrap_or(false);
2464                    if tasks_incomplete {
2465                        push_fail(
2466                            &mut checks,
2467                            &mut blocked_codes,
2468                            "execution state",
2469                            "complete but tasks are not all done/deferred".to_string(),
2470                            "state-tasks-incomplete",
2471                        );
2472                    } else {
2473                        push_pass(&mut checks, "execution state", "complete".to_string());
2474                    }
2475                }
2476                Some(value) => push_fail(
2477                    &mut checks,
2478                    &mut blocked_codes,
2479                    "execution state",
2480                    format!("latest state status is `{value}`"),
2481                    "state-not-complete",
2482                ),
2483                None => push_fail(
2484                    &mut checks,
2485                    &mut blocked_codes,
2486                    "execution state",
2487                    "missing payload status".to_string(),
2488                    "state-not-complete",
2489                ),
2490            }
2491        }
2492        None => push_fail(
2493            &mut checks,
2494            &mut blocked_codes,
2495            "execution state",
2496            "missing".to_string(),
2497            "state-missing",
2498        ),
2499    }
2500
2501    match audit.evidence.get("session") {
2502        Some(hit) => push_pass(
2503            &mut checks,
2504            "execution session",
2505            hit.url.as_deref().unwrap_or("present").to_string(),
2506        ),
2507        None => push_fail(
2508            &mut checks,
2509            &mut blocked_codes,
2510            "execution session",
2511            "missing role=session lifecycle record".to_string(),
2512            "session-missing",
2513        ),
2514    }
2515
2516    match audit.evidence.get("validation") {
2517        Some(hit) => match hit.status.as_deref() {
2518            Some("pass") => push_pass(&mut checks, "validation", "pass".to_string()),
2519            Some(value) => push_fail(
2520                &mut checks,
2521                &mut blocked_codes,
2522                "validation",
2523                format!("latest validation overall = `{value}`"),
2524                "validation-failed",
2525            ),
2526            None => push_fail(
2527                &mut checks,
2528                &mut blocked_codes,
2529                "validation",
2530                "missing payload status".to_string(),
2531                "validation-failed",
2532            ),
2533        },
2534        None => push_fail(
2535            &mut checks,
2536            &mut blocked_codes,
2537            "validation",
2538            "missing".to_string(),
2539            "validation-missing",
2540        ),
2541    }
2542
2543    match audit.evidence.get("review") {
2544        Some(hit) => {
2545            let parsed = hit.payload.as_ref().map(|payload| payload.parse_review());
2546            match parsed {
2547                Some(Ok(data)) => match data.decision {
2548                    ReviewDecision::RequestChanges => push_fail(
2549                        &mut checks,
2550                        &mut blocked_codes,
2551                        "review",
2552                        "decision = request-changes".to_string(),
2553                        "review-rejected",
2554                    ),
2555                    decision => {
2556                        let unresolved = data.findings.iter().any(|finding| {
2557                            matches!(finding.disposition, FindingDisposition::Residual)
2558                                && matches!(
2559                                    finding.severity,
2560                                    FindingSeverity::Blocker | FindingSeverity::Major
2561                                )
2562                        });
2563                        if unresolved {
2564                            push_fail(
2565                                &mut checks,
2566                                &mut blocked_codes,
2567                                "review",
2568                                "unresolved blocker/major findings".to_string(),
2569                                "review-unresolved-findings",
2570                            );
2571                        } else {
2572                            let label = match decision {
2573                                ReviewDecision::Approve => "approve",
2574                                ReviewDecision::CommentsOnly => "comments-only",
2575                                ReviewDecision::RequestChanges => unreachable!(),
2576                            };
2577                            push_pass(&mut checks, "review", format!("decision = {label}"));
2578                        }
2579                    }
2580                },
2581                Some(Err(err)) => push_fail(
2582                    &mut checks,
2583                    &mut blocked_codes,
2584                    "review",
2585                    format!("malformed review payload: {}", err.message),
2586                    "review-rejected",
2587                ),
2588                None => push_fail(
2589                    &mut checks,
2590                    &mut blocked_codes,
2591                    "review",
2592                    "missing payload".to_string(),
2593                    "review-missing",
2594                ),
2595            }
2596        }
2597        None => push_fail(
2598            &mut checks,
2599            &mut blocked_codes,
2600            "review",
2601            "missing".to_string(),
2602            "review-missing",
2603        ),
2604    }
2605
2606    let approval_text = input.approval.unwrap_or("").trim();
2607    if approval_text.is_empty() {
2608        push_fail(
2609            &mut checks,
2610            &mut blocked_codes,
2611            "close approval",
2612            "missing explicit approval".to_string(),
2613            "approval-missing",
2614        );
2615    } else {
2616        push_pass(&mut checks, "close approval", approval_text.to_string());
2617    }
2618
2619    if input.linked_prs.is_empty() {
2620        push_pass(&mut checks, "linked PRs", "none provided".to_string());
2621    } else {
2622        let mut unmerged: Vec<String> = Vec::new();
2623        let mut required_failed: Vec<String> = Vec::new();
2624        for pr in input.linked_prs {
2625            let sha = pr.merge_sha.as_deref().map(str::trim).unwrap_or("");
2626            if sha.is_empty() {
2627                unmerged.push(format!("{} (no merge_sha)", pr.pr_ref));
2628                continue;
2629            }
2630            match pr.required_state {
2631                Some(CheckStatus::Fail) => {
2632                    required_failed.push(format!("{} (required checks failed)", pr.pr_ref));
2633                }
2634                Some(CheckStatus::Pass | CheckStatus::None) => {
2635                    // Required checks resolved cleanly (including the
2636                    // `required_count == 0` case). Non-required failures
2637                    // are informational only and never block.
2638                }
2639                None => {
2640                    // Provider could not classify required-vs-non-required
2641                    // (e.g. GitLab today, or a degraded `gh` call). Stay
2642                    // conservative: aggregate failure blocks unless the
2643                    // caller has set the explicit override flag.
2644                    if matches!(pr.checks, CheckStatus::Fail)
2645                        && !input.allow_non_required_check_failure
2646                    {
2647                        required_failed.push(format!(
2648                            "{} (checks={:?}; required-state unknown)",
2649                            pr.pr_ref, pr.checks
2650                        ));
2651                    }
2652                }
2653            }
2654        }
2655        if !unmerged.is_empty() {
2656            push_fail(
2657                &mut checks,
2658                &mut blocked_codes,
2659                "linked PRs",
2660                unmerged.join(", "),
2661                "linked-pr-not-merged",
2662            );
2663        }
2664        if !required_failed.is_empty() {
2665            push_fail(
2666                &mut checks,
2667                &mut blocked_codes,
2668                "linked PRs required checks",
2669                required_failed.join(", "),
2670                "linked-pr-checks-failed",
2671            );
2672        }
2673        if unmerged.is_empty() && required_failed.is_empty() {
2674            push_pass(
2675                &mut checks,
2676                "linked PRs",
2677                format!("{} merged", input.linked_prs.len()),
2678            );
2679        }
2680    }
2681
2682    if let (Some(current), Some(expected)) = (input.current_body, input.expected_dashboard) {
2683        let current_norm = normalize_for_dashboard_compare(current);
2684        let expected_norm = normalize_for_dashboard_compare(expected);
2685        if current_norm.contains(&expected_norm) {
2686            push_pass(&mut checks, "dashboard", "matches canonical".to_string());
2687        } else {
2688            push_fail(
2689                &mut checks,
2690                &mut blocked_codes,
2691                "dashboard",
2692                "dashboard differs from recomputed canonical".to_string(),
2693                "dashboard-out-of-date",
2694            );
2695        }
2696    }
2697
2698    let ready = checks.iter().all(|check| check.status == "pass");
2699    StrictCloseoutGateResult {
2700        ready,
2701        checks,
2702        blocked_codes,
2703    }
2704}
2705
2706fn normalize_for_dashboard_compare(text: &str) -> String {
2707    text.lines()
2708        .map(str::trim_end)
2709        .collect::<Vec<_>>()
2710        .join("\n")
2711}
2712
2713#[cfg(test)]
2714mod sprint3_tests {
2715    use super::*;
2716    use serde_json::json;
2717
2718    fn build_audit_with_evidence(comments: Vec<(serde_json::Value, &str)>) -> RecordAudit {
2719        let payload = json!({
2720            "comments": comments
2721                .into_iter()
2722                .map(|(body, url)| json!({"body": body, "url": url, "created_at": "2026-05-23T08:00:00Z"}))
2723                .collect::<Vec<_>>()
2724        });
2725        audit_record(None, &payload.to_string(), None).expect("audit ok")
2726    }
2727
2728    fn v2_body(role: &str, data: Value) -> Value {
2729        let envelope = json!({
2730            "schema": PAYLOAD_SCHEMA_V2,
2731            "role": role,
2732            "profile": "tracking",
2733            "data": data,
2734        });
2735        let payload_json = serde_json::to_string(&envelope).expect("serialize");
2736        json!(format!(
2737            "<!-- plan-issue-record:v2 role={role} profile=tracking -->\n\n```{PAYLOAD_FENCE_INFO}\n{payload_json}\n```\n",
2738        ))
2739    }
2740
2741    #[test]
2742    fn audit_treats_v2_marker_without_payload_fence_as_payload_none() {
2743        // Reproduces [F11] deferred follow-up: v2 marker with no payload
2744        // fence should leave evidence.payload = None instead of erroring.
2745        let body_only_marker = json!(
2746            "<!-- plan-issue-record:v2 role=session profile=tracking -->\n\n## Execution Session\n\nfreeform notes, no payload\n"
2747        );
2748        let audit = build_audit_with_evidence(vec![(
2749            body_only_marker,
2750            "https://github.com/owner/repo/issues/1#issuecomment-session",
2751        )]);
2752        let session = audit
2753            .evidence
2754            .get("session")
2755            .expect("session evidence registered");
2756        assert!(session.payload.is_none(), "payload should be None");
2757        assert_eq!(audit.recognized_count, 1);
2758    }
2759
2760    #[test]
2761    fn audit_strict_fails_on_malformed_payload() {
2762        // [F11] deferred: malformed payload fence must error rather than
2763        // silently degrade to payload=None.
2764        let body = json!(
2765            "<!-- plan-issue-record:v2 role=state profile=tracking -->\n\n```plan-issue-record-payload\n{not valid json\n```\n"
2766        );
2767        let payload = json!({
2768            "comments": [{
2769                "body": body,
2770                "url": "https://github.com/owner/repo/issues/1#issuecomment-bad",
2771                "created_at": "2026-05-23T08:00:00Z",
2772            }]
2773        });
2774        let err = audit_record(None, &payload.to_string(), None)
2775            .expect_err("malformed payload should fail audit");
2776        assert!(
2777            err.contains("malformed payload"),
2778            "error should mention malformed payload: {err}"
2779        );
2780    }
2781
2782    #[test]
2783    fn strict_gate_passes_when_all_v2_evidence_complete_and_merged() {
2784        let state = v2_body(
2785            "state",
2786            json!({
2787                "status": "complete",
2788                "target_scope": "scope",
2789                "tasks": [
2790                    {"id": "1.1", "status": "done", "title": "x"},
2791                    {"id": "1.2", "status": "deferred", "title": "y"},
2792                ],
2793                "prs": [{"ref": "owner/repo#1", "url": "u", "status": "merged"}],
2794                "blockers": [],
2795                "links": {},
2796            }),
2797        );
2798        let validation = v2_body(
2799            "validation",
2800            json!({"overall": "pass", "commands": [], "waivers": []}),
2801        );
2802        let review = v2_body(
2803            "review",
2804            json!({
2805                "decision": "approve",
2806                "lenses": ["testing"],
2807                "findings": [],
2808            }),
2809        );
2810        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2811        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2812        let session = v2_body("session", json!({"summary": "session complete"}));
2813        let audit = build_audit_with_evidence(vec![
2814            (source, "u-src"),
2815            (plan, "u-plan"),
2816            (state, "u-state"),
2817            (session, "u-session"),
2818            (validation, "u-val"),
2819            (review, "u-rev"),
2820        ]);
2821
2822        let linked_prs = vec![LinkedPrEvidence {
2823            pr_ref: "owner/repo#1".to_string(),
2824            url: Some("https://github.com/owner/repo/pull/1".to_string()),
2825            merge_sha: Some("abcdef1234567890".to_string()),
2826            checks: CheckStatus::Pass,
2827            required_state: Some(CheckStatus::Pass),
2828            required_count: Some(1),
2829            non_required_failures: Vec::new(),
2830        }];
2831        let result = evaluate_strict_closeout_gate(
2832            &audit,
2833            StrictCloseoutGateInput {
2834                profile: RecordProfile::Tracking,
2835                approval: Some("https://github.com/owner/repo/issues/1#issuecomment-9"),
2836                linked_prs: &linked_prs,
2837                current_body: None,
2838                expected_dashboard: None,
2839                allow_non_required_check_failure: false,
2840            },
2841        );
2842        assert!(result.ready, "gate should pass: {:?}", result.checks);
2843        assert!(result.blocked_codes.is_empty());
2844    }
2845
2846    #[test]
2847    fn strict_gate_blocks_when_state_not_complete() {
2848        let state = v2_body(
2849            "state",
2850            json!({"status": "in-progress", "target_scope": "s", "tasks": [], "prs": [], "blockers": [], "links": {}}),
2851        );
2852        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2853        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2854        let session = v2_body("session", json!({"summary": "session complete"}));
2855        let validation = v2_body("validation", json!({"overall": "pass"}));
2856        let review = v2_body("review", json!({"decision": "approve"}));
2857        let audit = build_audit_with_evidence(vec![
2858            (source, "a"),
2859            (plan, "b"),
2860            (state, "c"),
2861            (session, "d"),
2862            (validation, "d"),
2863            (review, "e"),
2864        ]);
2865        let result = evaluate_strict_closeout_gate(
2866            &audit,
2867            StrictCloseoutGateInput {
2868                profile: RecordProfile::Tracking,
2869                approval: Some("ok"),
2870                linked_prs: &[],
2871                current_body: None,
2872                expected_dashboard: None,
2873                allow_non_required_check_failure: false,
2874            },
2875        );
2876        assert!(!result.ready);
2877        assert!(
2878            result
2879                .blocked_codes
2880                .iter()
2881                .any(|c| c == "state-not-complete"),
2882            "{:?}",
2883            result.blocked_codes
2884        );
2885    }
2886
2887    #[test]
2888    fn strict_gate_blocks_when_review_rejected_or_unresolved() {
2889        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2890        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2891        let state = v2_body(
2892            "state",
2893            json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
2894        );
2895        let session = v2_body("session", json!({"summary": "session complete"}));
2896        let validation = v2_body("validation", json!({"overall": "pass"}));
2897        let review_rejected = v2_body("review", json!({"decision": "request-changes"}));
2898        let audit_rej = build_audit_with_evidence(vec![
2899            (source.clone(), "a"),
2900            (plan.clone(), "b"),
2901            (state.clone(), "c"),
2902            (session.clone(), "d"),
2903            (validation.clone(), "d"),
2904            (review_rejected, "e"),
2905        ]);
2906        let res_rej = evaluate_strict_closeout_gate(
2907            &audit_rej,
2908            StrictCloseoutGateInput {
2909                profile: RecordProfile::Tracking,
2910                approval: Some("ok"),
2911                linked_prs: &[],
2912                current_body: None,
2913                expected_dashboard: None,
2914                allow_non_required_check_failure: false,
2915            },
2916        );
2917        assert!(res_rej.blocked_codes.iter().any(|c| c == "review-rejected"));
2918
2919        let review_unresolved = v2_body(
2920            "review",
2921            json!({
2922                "decision": "approve",
2923                "findings": [
2924                    {"id": "F1", "severity": "blocker", "disposition": "residual", "summary": "x"}
2925                ]
2926            }),
2927        );
2928        let audit_un = build_audit_with_evidence(vec![
2929            (source, "a"),
2930            (plan, "b"),
2931            (state, "c"),
2932            (session, "d"),
2933            (validation, "d"),
2934            (review_unresolved, "e"),
2935        ]);
2936        let res_un = evaluate_strict_closeout_gate(
2937            &audit_un,
2938            StrictCloseoutGateInput {
2939                profile: RecordProfile::Tracking,
2940                approval: Some("ok"),
2941                linked_prs: &[],
2942                current_body: None,
2943                expected_dashboard: None,
2944                allow_non_required_check_failure: false,
2945            },
2946        );
2947        assert!(
2948            res_un
2949                .blocked_codes
2950                .iter()
2951                .any(|c| c == "review-unresolved-findings")
2952        );
2953    }
2954
2955    #[test]
2956    fn strict_gate_blocks_when_linked_pr_missing_merge_sha() {
2957        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2958        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2959        let state = v2_body(
2960            "state",
2961            json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
2962        );
2963        let session = v2_body("session", json!({"summary": "session complete"}));
2964        let validation = v2_body("validation", json!({"overall": "pass"}));
2965        let review = v2_body("review", json!({"decision": "approve"}));
2966        let audit = build_audit_with_evidence(vec![
2967            (source, "a"),
2968            (plan, "b"),
2969            (state, "c"),
2970            (session, "d"),
2971            (validation, "d"),
2972            (review, "e"),
2973        ]);
2974        let linked = vec![LinkedPrEvidence {
2975            pr_ref: "owner/repo#1".to_string(),
2976            url: None,
2977            merge_sha: None,
2978            checks: CheckStatus::Pass,
2979            required_state: Some(CheckStatus::Pass),
2980            required_count: Some(0),
2981            non_required_failures: Vec::new(),
2982        }];
2983        let res = evaluate_strict_closeout_gate(
2984            &audit,
2985            StrictCloseoutGateInput {
2986                profile: RecordProfile::Tracking,
2987                approval: Some("ok"),
2988                linked_prs: &linked,
2989                current_body: None,
2990                expected_dashboard: None,
2991                allow_non_required_check_failure: false,
2992            },
2993        );
2994        assert!(
2995            res.blocked_codes
2996                .iter()
2997                .any(|c| c == "linked-pr-not-merged")
2998        );
2999    }
3000
3001    #[test]
3002    fn strict_gate_passes_with_non_required_failure_when_required_pass() {
3003        // Regression for sympoies/nils-cli#502: a non-required check
3004        // failure with required-state success must not block the gate.
3005        let audit = build_audit_with_evidence(vec![
3006            (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3007            (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3008            (
3009                v2_body(
3010                    "state",
3011                    json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3012                ),
3013                "c",
3014            ),
3015            (
3016                v2_body("session", json!({"summary": "session complete"})),
3017                "d",
3018            ),
3019            (v2_body("validation", json!({"overall": "pass"})), "d"),
3020            (v2_body("review", json!({"decision": "approve"})), "e"),
3021        ]);
3022        let linked = vec![LinkedPrEvidence {
3023            pr_ref: "owner/repo#1".to_string(),
3024            url: None,
3025            merge_sha: Some("abc".to_string()),
3026            checks: CheckStatus::Fail,
3027            required_state: Some(CheckStatus::Pass),
3028            required_count: Some(0),
3029            non_required_failures: vec!["scripts/ci/all.sh".to_string()],
3030        }];
3031        let res = evaluate_strict_closeout_gate(
3032            &audit,
3033            StrictCloseoutGateInput {
3034                profile: RecordProfile::Tracking,
3035                approval: Some("ok"),
3036                linked_prs: &linked,
3037                current_body: None,
3038                expected_dashboard: None,
3039                allow_non_required_check_failure: false,
3040            },
3041        );
3042        assert!(res.ready, "blocked: {:?}", res.blocked_codes);
3043        assert!(res.blocked_codes.is_empty(), "{:?}", res.blocked_codes);
3044    }
3045
3046    #[test]
3047    fn strict_gate_emits_linked_pr_checks_failed_when_required_fail() {
3048        let audit = build_audit_with_evidence(vec![
3049            (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3050            (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3051            (
3052                v2_body(
3053                    "state",
3054                    json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3055                ),
3056                "c",
3057            ),
3058            (
3059                v2_body("session", json!({"summary": "session complete"})),
3060                "d",
3061            ),
3062            (v2_body("validation", json!({"overall": "pass"})), "d"),
3063            (v2_body("review", json!({"decision": "approve"})), "e"),
3064        ]);
3065        let linked = vec![LinkedPrEvidence {
3066            pr_ref: "owner/repo#1".to_string(),
3067            url: None,
3068            merge_sha: Some("abc".to_string()),
3069            checks: CheckStatus::Fail,
3070            required_state: Some(CheckStatus::Fail),
3071            required_count: Some(2),
3072            non_required_failures: Vec::new(),
3073        }];
3074        let res = evaluate_strict_closeout_gate(
3075            &audit,
3076            StrictCloseoutGateInput {
3077                profile: RecordProfile::Tracking,
3078                approval: Some("ok"),
3079                linked_prs: &linked,
3080                current_body: None,
3081                expected_dashboard: None,
3082                allow_non_required_check_failure: false,
3083            },
3084        );
3085        assert!(
3086            res.blocked_codes
3087                .iter()
3088                .any(|c| c == "linked-pr-checks-failed"),
3089            "expected linked-pr-checks-failed, got {:?}",
3090            res.blocked_codes
3091        );
3092        assert!(
3093            !res.blocked_codes
3094                .iter()
3095                .any(|c| c == "linked-pr-not-merged"),
3096            "must not collapse into linked-pr-not-merged"
3097        );
3098    }
3099
3100    #[test]
3101    fn strict_gate_override_unblocks_unknown_required_state_aggregate_fail() {
3102        let audit = build_audit_with_evidence(vec![
3103            (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3104            (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3105            (
3106                v2_body(
3107                    "state",
3108                    json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3109                ),
3110                "c",
3111            ),
3112            (
3113                v2_body("session", json!({"summary": "session complete"})),
3114                "d",
3115            ),
3116            (v2_body("validation", json!({"overall": "pass"})), "d"),
3117            (v2_body("review", json!({"decision": "approve"})), "e"),
3118        ]);
3119        let linked = vec![LinkedPrEvidence {
3120            pr_ref: "owner/repo#1".to_string(),
3121            url: None,
3122            merge_sha: Some("abc".to_string()),
3123            checks: CheckStatus::Fail,
3124            required_state: None,
3125            required_count: None,
3126            non_required_failures: vec!["opt-in/lint".to_string()],
3127        }];
3128
3129        let blocked = evaluate_strict_closeout_gate(
3130            &audit,
3131            StrictCloseoutGateInput {
3132                profile: RecordProfile::Tracking,
3133                approval: Some("ok"),
3134                linked_prs: &linked,
3135                current_body: None,
3136                expected_dashboard: None,
3137                allow_non_required_check_failure: false,
3138            },
3139        );
3140        assert!(
3141            blocked
3142                .blocked_codes
3143                .iter()
3144                .any(|c| c == "linked-pr-checks-failed"),
3145            "conservative path blocks: {:?}",
3146            blocked.blocked_codes
3147        );
3148
3149        let unblocked = evaluate_strict_closeout_gate(
3150            &audit,
3151            StrictCloseoutGateInput {
3152                profile: RecordProfile::Tracking,
3153                approval: Some("ok"),
3154                linked_prs: &linked,
3155                current_body: None,
3156                expected_dashboard: None,
3157                allow_non_required_check_failure: true,
3158            },
3159        );
3160        assert!(unblocked.ready, "{:?}", unblocked.blocked_codes);
3161    }
3162
3163    #[test]
3164    fn strict_gate_blocks_when_approval_empty() {
3165        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
3166        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
3167        let state = v2_body(
3168            "state",
3169            json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3170        );
3171        let session = v2_body("session", json!({"summary": "session complete"}));
3172        let validation = v2_body("validation", json!({"overall": "pass"}));
3173        let review = v2_body("review", json!({"decision": "approve"}));
3174        let audit = build_audit_with_evidence(vec![
3175            (source, "a"),
3176            (plan, "b"),
3177            (state, "c"),
3178            (session, "d"),
3179            (validation, "d"),
3180            (review, "e"),
3181        ]);
3182        let res = evaluate_strict_closeout_gate(
3183            &audit,
3184            StrictCloseoutGateInput {
3185                profile: RecordProfile::Tracking,
3186                approval: Some("   "),
3187                linked_prs: &[],
3188                current_body: None,
3189                expected_dashboard: None,
3190                allow_non_required_check_failure: false,
3191            },
3192        );
3193        assert!(res.blocked_codes.iter().any(|c| c == "approval-missing"));
3194    }
3195
3196    #[test]
3197    fn render_record_post_comment_emits_marker_and_hidden_payload_carrier() {
3198        let body = render_record_post_comment(
3199            RecordProfile::Tracking,
3200            LifecycleCommentKind::State,
3201            json!({"status": "complete", "tasks": [], "prs": []}),
3202            Some("session summary"),
3203            Some("2026-05-23T08:42:11Z"),
3204        )
3205        .expect("render");
3206        assert!(
3207            body.starts_with("<!-- plan-issue-record:v2 role=state profile=tracking -->"),
3208            "{body}"
3209        );
3210        assert!(
3211            !body.contains(&format!("```{PAYLOAD_FENCE_INFO}")),
3212            "{body}"
3213        );
3214        assert!(body.contains(PAYLOAD_COMMENT_PREFIX), "{body}");
3215        let payload = extract_payload(&body).expect("payload");
3216        assert_eq!(payload.schema, PAYLOAD_SCHEMA_V2);
3217        assert_eq!(payload.role, PayloadRole::State);
3218        assert!(body.contains("session summary"), "{body}");
3219    }
3220
3221    fn state_summary_with_task_ledger() -> &'static str {
3222        "## Execution State\n\n\
3223         - Status: in-progress\n\n\
3224         ## Task Ledger\n\n\
3225         | ID | Status | Task |\n\
3226         | --- | --- | --- |\n\
3227         | 1.1 | pending | Demo task |\n"
3228    }
3229
3230    fn render_state_with_display(display: TaskLedgerDisplay) -> String {
3231        render_record_post_comment_with_display(
3232            RecordProfile::Tracking,
3233            LifecycleCommentKind::State,
3234            json!({
3235                "status": "in-progress",
3236                "tasks": [{"id": "1.1", "status": "pending", "title": "Demo task"}],
3237                "prs": [],
3238                "blockers": [],
3239                "links": {}
3240            }),
3241            Some(state_summary_with_task_ledger()),
3242            None,
3243            display,
3244        )
3245        .expect("render")
3246    }
3247
3248    #[test]
3249    fn task_ledger_display_open_emits_open_fold() {
3250        let body = render_state_with_display(TaskLedgerDisplay::Open);
3251        assert!(body.contains("<details open>"), "{body}");
3252        assert!(
3253            body.contains("<summary>Show task ledger</summary>"),
3254            "{body}"
3255        );
3256        assert!(body.contains("| 1.1 | pending | Demo task |"), "{body}");
3257    }
3258
3259    #[test]
3260    fn task_ledger_display_collapsed_emits_closed_fold() {
3261        let body = render_state_with_display(TaskLedgerDisplay::Collapsed);
3262        assert!(body.contains("<details>"), "{body}");
3263        assert!(!body.contains("<details open>"), "{body}");
3264    }
3265
3266    #[test]
3267    fn task_ledger_display_expanded_emits_no_fold() {
3268        let body = render_state_with_display(TaskLedgerDisplay::Expanded);
3269        assert!(!body.contains("<details"), "{body}");
3270        assert!(body.contains("| 1.1 | pending | Demo task |"), "{body}");
3271    }
3272
3273    #[test]
3274    fn render_record_post_comment_synthesizes_validation_review_and_closeout() {
3275        let validation = render_record_post_comment(
3276            RecordProfile::Tracking,
3277            LifecycleCommentKind::Validation,
3278            json!({
3279                "overall": "pass",
3280                "commands": [{"command": "cargo test", "status": "pass", "evidence": "ok"}],
3281                "waivers": []
3282            }),
3283            None,
3284            None,
3285        )
3286        .expect("validation render");
3287        assert!(validation.contains("- Overall: pass"), "{validation}");
3288        assert!(
3289            validation.contains("| cargo test | pass | ok |"),
3290            "{validation}"
3291        );
3292        assert!(validation.contains(PAYLOAD_COMMENT_PREFIX), "{validation}");
3293
3294        let review = render_record_post_comment(
3295            RecordProfile::Tracking,
3296            LifecycleCommentKind::Review,
3297            json!({
3298                "decision": "approve",
3299                "lenses": ["testing", "maintainability"],
3300                "findings": [{
3301                    "id": "F1",
3302                    "severity": "minor",
3303                    "disposition": "fixed",
3304                    "summary": "covered"
3305                }],
3306                "outcome_comment_url": "https://example.test/review"
3307            }),
3308            None,
3309            None,
3310        )
3311        .expect("review render");
3312        assert!(review.contains("- Decision: approve"), "{review}");
3313        assert!(
3314            review.contains("- Lenses: testing, maintainability"),
3315            "{review}"
3316        );
3317        assert!(
3318            review.contains("| F1 | minor | fixed | covered |"),
3319            "{review}"
3320        );
3321
3322        let closeout = render_record_post_comment(
3323            RecordProfile::Tracking,
3324            LifecycleCommentKind::Closeout,
3325            json!({
3326                "final_status": "complete",
3327                "approval": {"comment_url": "https://example.test/approval"},
3328                "linked_prs": [{
3329                    "ref": "owner/repo#1",
3330                    "url": "https://example.test/pr/1",
3331                    "merge_sha": "abc123",
3332                    "checks": "pass",
3333                    "required_state": "pass",
3334                    "required_count": 2,
3335                    "non_required_failures": []
3336                }],
3337                "non_required_check_override": {
3338                    "reason": "operator accepted non-required lint",
3339                    "observed_non_required_failures": ["owner/repo#1: opt-in/lint"]
3340                },
3341                "notes": "closed"
3342            }),
3343            Some("Closeout summary."),
3344            None,
3345        )
3346        .expect("closeout render");
3347        assert!(closeout.contains("Closeout summary."), "{closeout}");
3348        assert!(closeout.contains("- Final status: complete"), "{closeout}");
3349        assert!(
3350            closeout.contains("| https://example.test/pr/1 | abc123 | pass | pass (2) | none |"),
3351            "{closeout}"
3352        );
3353        assert!(
3354            closeout.contains("- Reason: operator accepted non-required lint"),
3355            "{closeout}"
3356        );
3357        assert!(
3358            closeout.contains("- Observed failures: owner/repo#1: opt-in/lint"),
3359            "{closeout}"
3360        );
3361
3362        let no_pr_closeout = render_record_post_comment(
3363            RecordProfile::Tracking,
3364            LifecycleCommentKind::Closeout,
3365            json!({
3366                "final_status": "complete",
3367                "approval": {"comment_url": "https://example.test/approval"},
3368                "linked_prs": [],
3369                "notes": "closed without linked PR"
3370            }),
3371            Some("Closeout summary."),
3372            None,
3373        )
3374        .expect("closeout render");
3375        assert!(
3376            no_pr_closeout.contains("- Linked PRs: none"),
3377            "{no_pr_closeout}"
3378        );
3379    }
3380
3381    #[test]
3382    fn render_record_post_comment_rejects_source_or_plan() {
3383        let err = render_record_post_comment(
3384            RecordProfile::Tracking,
3385            LifecycleCommentKind::Source,
3386            json!({}),
3387            None,
3388            None,
3389        )
3390        .expect_err("must reject source");
3391        assert!(err.contains("source"), "{err}");
3392    }
3393
3394    #[test]
3395    fn render_record_snapshot_comment_includes_details_and_hidden_payload() {
3396        let snapshot = SnapshotData {
3397            path: "docs/plans/sample/sample-plan.md".to_string(),
3398            commit: "abc1234".to_string(),
3399            title: Some("Sample Plan".to_string()),
3400            summary: Some("One-liner".to_string()),
3401        };
3402        let body = render_record_snapshot_comment(
3403            RecordProfile::Tracking,
3404            LifecycleCommentKind::Plan,
3405            &snapshot,
3406            "# Sample Plan\n\nbody...\n",
3407            Some("2026-05-23T08:42:11Z"),
3408        )
3409        .expect("render");
3410        assert!(
3411            body.contains("- Path: `docs/plans/sample/sample-plan.md`"),
3412            "{body}"
3413        );
3414        assert!(body.contains("- Commit: `abc1234`"), "{body}");
3415        assert!(body.contains("- Summary: One-liner"), "{body}");
3416        assert!(body.contains("<details>"), "{body}");
3417        assert!(
3418            !body.contains(&format!("```{PAYLOAD_FENCE_INFO}")),
3419            "{body}"
3420        );
3421        assert!(body.contains(PAYLOAD_COMMENT_PREFIX), "{body}");
3422        let payload = extract_payload(&body).expect("payload");
3423        assert_eq!(payload.schema, PAYLOAD_SCHEMA_V2);
3424        assert_eq!(payload.role, PayloadRole::Plan);
3425    }
3426
3427    #[test]
3428    fn extract_payload_ignores_payload_markers_inside_snapshot_details() {
3429        let nested_payload = RecordPayload {
3430            schema: PAYLOAD_SCHEMA_V2.to_string(),
3431            role: PayloadRole::State,
3432            profile: PayloadProfile::Tracking,
3433            updated_at: None,
3434            data: json!({"status": "complete"}),
3435        };
3436        let nested_carrier = render_payload_carrier(&nested_payload).expect("nested carrier");
3437        let snapshot = SnapshotData {
3438            path: "docs/plans/sample/sample-discussion-source.md".to_string(),
3439            commit: "abc1234".to_string(),
3440            title: None,
3441            summary: None,
3442        };
3443        let body = render_record_snapshot_comment(
3444            RecordProfile::Tracking,
3445            LifecycleCommentKind::Source,
3446            &snapshot,
3447            &format!(
3448                "# Source\n\n{nested_carrier}\n\n```{PAYLOAD_FENCE_INFO}\n{{not valid json}}\n```\n"
3449            ),
3450            None,
3451        )
3452        .expect("render");
3453
3454        let payload = extract_payload(&body).expect("payload");
3455        assert_eq!(payload.role, PayloadRole::Source);
3456    }
3457
3458    #[test]
3459    fn required_check_label_emits_five_distinct_branches() {
3460        // `Some(Pass) + Some(0)` is the "no required-check rule" case
3461        // observed on sympoies/nils-cli#541's closeout — was previously
3462        // collapsed into "unknown".
3463        assert_eq!(
3464            required_check_label(Some(CheckStatus::Pass), Some(0)),
3465            "none required"
3466        );
3467
3468        // `Some(Pass) + Some(N>=1)` keeps the existing "pass (N)" shape.
3469        assert_eq!(
3470            required_check_label(Some(CheckStatus::Pass), Some(3)),
3471            "pass (3)"
3472        );
3473
3474        // `Some(Pass) + None` is the defensive case for adapters that
3475        // know the state but not the count.
3476        assert_eq!(required_check_label(Some(CheckStatus::Pass), None), "pass");
3477
3478        // `Some(Fail) + Some(N)` keeps the existing "fail (N)" shape.
3479        assert_eq!(
3480            required_check_label(Some(CheckStatus::Fail), Some(2)),
3481            "fail (2)"
3482        );
3483        assert_eq!(required_check_label(Some(CheckStatus::Fail), None), "fail");
3484
3485        // `Some(None)` is the aggregate-rollup-absent case (PR #554 on
3486        // #541's closeout — GHA never registered any check suite).
3487        assert_eq!(
3488            required_check_label(Some(CheckStatus::None), Some(0)),
3489            "none"
3490        );
3491        assert_eq!(required_check_label(Some(CheckStatus::None), None), "none");
3492
3493        // `None` is the catch-all for probe failures / fixture omissions.
3494        assert_eq!(required_check_label(None, None), "unknown");
3495        assert_eq!(required_check_label(None, Some(0)), "unknown");
3496    }
3497
3498    // Snapshot tests below lock the full byte-for-byte wire shape of
3499    // `render_record_post_comment` for each lifecycle kind. The existing
3500    // `contains` assertions cover individual fields; these goldens guard
3501    // against silent template/serializer drift that re-orders lines, drops
3502    // sections, or grows a new field downstream consumers don't expect.
3503
3504    fn golden_dump(label: &str, body: &str) {
3505        if std::env::var("LIFECYCLE_RECORD_GOLDEN_DUMP").is_ok() {
3506            eprintln!("--- BEGIN {label} ---\n{body}\n--- END {label} ---");
3507        }
3508    }
3509
3510    #[test]
3511    fn golden_state_post_comment_locks_full_wire_shape() {
3512        let body = render_record_post_comment(
3513            RecordProfile::Tracking,
3514            LifecycleCommentKind::State,
3515            json!({
3516                "status": "complete",
3517                "target_scope": "PR #599 follow-ups",
3518                "current": "delivering snapshot tests",
3519                "next_action": "open closeout comment",
3520                "tasks": [
3521                    {"id": "1.1", "status": "done", "title": "ship URL parser"},
3522                    {"id": "1.2", "status": "in-progress", "title": "ship snapshots"},
3523                ],
3524                "prs": [{"ref": "owner/repo#1", "url": "https://example.test/pr/1", "status": "merged"}],
3525                "blockers": [],
3526                "links": {},
3527            }),
3528            None,
3529            Some("2026-05-23T08:42:11Z"),
3530        )
3531        .expect("state render");
3532        golden_dump("state", &body);
3533        assert_eq!(
3534            body,
3535            include_str!("snapshots/state_post_comment.md"),
3536            "state post-comment shape drifted; run with LIFECYCLE_RECORD_GOLDEN_DUMP=1 to dump"
3537        );
3538    }
3539
3540    #[test]
3541    fn golden_validation_post_comment_locks_full_wire_shape() {
3542        let body = render_record_post_comment(
3543            RecordProfile::Tracking,
3544            LifecycleCommentKind::Validation,
3545            json!({
3546                "overall": "pass",
3547                "commands": [
3548                    {"command": "cargo test --workspace", "status": "pass", "evidence": "all green"},
3549                    {"command": "scripts/ci/local-fast.sh", "status": "pass", "evidence": "ok"},
3550                ],
3551                "waivers": [],
3552            }),
3553            None,
3554            Some("2026-05-23T08:42:11Z"),
3555        )
3556        .expect("validation render");
3557        golden_dump("validation", &body);
3558        assert_eq!(
3559            body,
3560            include_str!("snapshots/validation_post_comment.md"),
3561            "validation post-comment shape drifted"
3562        );
3563    }
3564
3565    #[test]
3566    fn golden_review_post_comment_locks_full_wire_shape() {
3567        let body = render_record_post_comment(
3568            RecordProfile::Tracking,
3569            LifecycleCommentKind::Review,
3570            json!({
3571                "decision": "approve",
3572                "lenses": ["testing", "maintainability"],
3573                "findings": [
3574                    {"id": "F1", "severity": "minor", "disposition": "fixed", "summary": "covered"},
3575                ],
3576                "outcome_comment_url": "https://example.test/review",
3577            }),
3578            None,
3579            Some("2026-05-23T08:42:11Z"),
3580        )
3581        .expect("review render");
3582        golden_dump("review", &body);
3583        assert_eq!(
3584            body,
3585            include_str!("snapshots/review_post_comment.md"),
3586            "review post-comment shape drifted"
3587        );
3588    }
3589
3590    #[test]
3591    fn golden_closeout_post_comment_locks_full_wire_shape() {
3592        let body = render_record_post_comment(
3593            RecordProfile::Tracking,
3594            LifecycleCommentKind::Closeout,
3595            json!({
3596                "final_status": "complete",
3597                "approval": {"comment_url": "https://example.test/approval"},
3598                "linked_prs": [{
3599                    "ref": "owner/repo#1",
3600                    "url": "https://example.test/pr/1",
3601                    "merge_sha": "abc1234",
3602                    "checks": "pass",
3603                    "required_state": "pass",
3604                    "required_count": 2,
3605                    "non_required_failures": []
3606                }],
3607                "notes": "shipped"
3608            }),
3609            Some("Closeout summary."),
3610            Some("2026-05-23T08:42:11Z"),
3611        )
3612        .expect("closeout render");
3613        golden_dump("closeout", &body);
3614        assert_eq!(
3615            body,
3616            include_str!("snapshots/closeout_post_comment.md"),
3617            "closeout post-comment shape drifted"
3618        );
3619    }
3620}