Skip to main content

kranz_engine/
planning.rs

1//! Planning turns — extracted from `orchestrator.rs` in the monolith split
2//! (pure code motion, no behavior change). The plan-request turn family:
3//! demanding the initial plan JSON and the revised plan for a running mission,
4//! the large-scope prompt policies those turns embed (considered-alternatives
5//! and research), the pre-emit validation gates a proposed (revised) plan must
6//! pass, the plan JSON Schema, and the missions-catalog index upsert.
7
8use crate::cost;
9use crate::error::{EngineError, Result};
10use crate::orchestrator::{MissionEngine, PlanRequest, JSON_RETRY_MSG};
11use crate::reducer;
12use crate::report_render::extract_research;
13use crate::runner;
14use crate::types::*;
15
16/// Content identity carried from a plan preview to its approval on every client.
17/// Serializing the typed plan fixes field order and includes all consent-bearing
18/// fields. Plans contain only infallibly serializable structs, strings and lists.
19pub fn plan_identity(plan: &Plan) -> String {
20    let json = serde_json::to_vec(plan).expect("Plan serialization cannot fail");
21    crate::standards_waiver::sha256_hex(&json)
22}
23
24impl MissionEngine {
25    /// Demand the plan JSON (types::Plan, camelCase). Lenient parse with one
26    /// retry demanding bare JSON; a plan parses to [`PlanRequest::Ready`]
27    /// (unapproved). Beside the plan, the planner has two more voices: an
28    /// explicit `{"wrongPlan": "…"}` reply maps to [`PlanRequest::WrongPlan`]
29    /// (planner-initiated only, never inferred from prose), and when the
30    /// retry ALSO answers with prose, the orchestrator is simply not ready to
31    /// emit (it wants answers first) — that text comes back as
32    /// [`PlanRequest::NotReady`], never as an error.
33    pub async fn request_plan(&mut self) -> Result<PlanRequest> {
34        let message = format!(
35            "Emit the plan now. Output ONLY a JSON object conforming exactly to this JSON \
36             Schema — no prose before or after:\n{}\n\n{}\n\n{}\n\n{}",
37            plan_schema(),
38            considered_alternatives_prompt_policy(&self.state.config),
39            research_prompt_policy(&self.state.config),
40            WRONG_PLAN_PROMPT_CHANNEL
41        );
42        let text = self.orch_turn(&message).await?;
43        if let Some(plan) = runner::parse_report::<Plan>(&text) {
44            self.pending_research = extract_research(&text);
45            return self.standards_fixed_point(plan).await;
46        }
47        if let Some(reason) = parse_wrong_plan(&text) {
48            return Ok(PlanRequest::WrongPlan { reason });
49        }
50        let retry = self.orch_turn(JSON_RETRY_MSG).await?;
51        if let Some(plan) = runner::parse_report::<Plan>(&retry) {
52            self.pending_research = extract_research(&retry);
53            return self.standards_fixed_point(plan).await;
54        }
55        if let Some(reason) = parse_wrong_plan(&retry) {
56            return Ok(PlanRequest::WrongPlan { reason });
57        }
58        // Prefer the retry's text (the model's latest word); fall back to
59        // the first turn's when the retry came back empty. Both are
60        // already scrubbed by pump_turn.
61        Ok(PlanRequest::NotReady(if retry.trim().is_empty() {
62            text
63        } else {
64            retry
65        }))
66    }
67
68    fn review_policy_preview(&self, mut plan: Plan) -> Result<PlanRequest> {
69        let policy = if self.state.mission.status == crate::types::MissionStatus::Planning {
70            crate::reviewer_independence::configured_policy(&self.state.config)
71        } else {
72            self.state.mission.reviewer_independence
73        };
74        crate::reviewer_independence::pin_plan(&mut plan, policy)?;
75        Ok(PlanRequest::Ready(plan))
76    }
77
78    /// The Flight Rules planning projection (KRZ-345, design D-D/D-G):
79    /// planning-stage rules resolved from the TRUSTED source — against the
80    /// seed hints (ticket repo-refs + any explicit touch hints) when
81    /// `touch_override` is `None`, or against the plan's own touch set for
82    /// the fixed-point revision loop. `Ok(None)` — no standards-configured
83    /// pack — leaves every planning surface byte-identical.
84    pub(crate) fn planning_standards_projection(
85        &self,
86        touch_override: Option<&[String]>,
87    ) -> Result<Option<crate::pack::projection::PlanningProjection>> {
88        let task_class = crate::ticket::parse_task_class_from_goal(&self.state.mission.goal);
89        let mut hints = match touch_override {
90            Some(touch) => touch.to_vec(),
91            None => self.planning_touch_hints(),
92        };
93        if let Some(contract) = crate::review_artifact::parse_from_goal(&self.state.mission.goal)? {
94            hints.push(contract.input_path);
95            hints.sort();
96            hints.dedup();
97        }
98        crate::pack::projection::planning_projection(
99            &self.repo,
100            &self.state.config,
101            &self.state.mission.base_branch,
102            task_class.as_deref(),
103            &hints,
104        )
105        .map_err(EngineError::Config)
106    }
107
108    /// The seed-time selection hints (D-D's "ticket repo refs and explicit
109    /// touch hints"): the mission's explicit touch hints (empty during
110    /// initial planning) plus the linked ticket's repo-refs. The ticket read
111    /// is best-effort — hints shape a conservative candidate set, never
112    /// policy; the pack load is the fail-closed part.
113    fn planning_touch_hints(&self) -> Vec<String> {
114        let mut hints = self.state.mission.touch_set.clone();
115        if let Some(slug) =
116            crate::ticket::Ticket::slug_for_mission(&self.paths.repo_root, &self.state.mission.id)
117        {
118            if let Ok(ticket) = crate::ticket::Ticket::load(&crate::ticket::Ticket::md_path(
119                &self.paths.repo_root,
120                &slug,
121            )) {
122                hints.extend(ticket.repo_refs);
123            }
124        }
125        hints
126    }
127
128    /// The Flight Rules fixed-point revision loop (KRZ-345, design D-D): the
129    /// planner received the planning-stage candidate set with its seed; when
130    /// the RETURNED plan's touch set activates additional planning-stage
131    /// rules the planner never saw, the exact delta goes back in one bounded
132    /// revision turn and resolution repeats against the revised plan, until
133    /// the plan/rule set reaches a fixed point (the plan activates nothing
134    /// the planner did not receive) or the turn budget runs out and planning
135    /// parks. The plan is never offered for approval carrying a rule the
136    /// planner did not receive; approval re-resolves and pins only the fixed
137    /// point (KRZ-342's approval re-resolution stays the authority). No
138    /// standards ⇒ the plan passes through untouched, byte-identical.
139    async fn standards_fixed_point(&mut self, plan: Plan) -> Result<PlanRequest> {
140        let Some(seed_projection) = self.planning_standards_projection(None)? else {
141            return self.review_policy_preview(plan);
142        };
143        let mut delivered: std::collections::BTreeSet<(String, u64)> =
144            seed_projection.delivered().into_iter().collect();
145        let mut plan = plan;
146        let mut revisions = 0;
147        loop {
148            let Some(current) = self.planning_standards_projection(Some(&plan.touch_set))? else {
149                // Standards stopped governing between the seed and now (the
150                // configured pack changed mid-planning): nothing the planner
151                // saw can be missing — approval re-resolves the truth.
152                return self.review_policy_preview(plan);
153            };
154            let delta: Vec<crate::pack::projection::ProjectedRule> = current
155                .rules
156                .iter()
157                .filter(|rule| !delivered.contains(&rule.identity()))
158                .cloned()
159                .collect();
160            if delta.is_empty() {
161                return self.review_policy_preview(plan);
162            }
163            if revisions == MAX_STANDARDS_REVISION_TURNS {
164                // The touch set kept activating unseen policy past the
165                // revision budget: park honestly, naming the rules still
166                // unaccounted for (D-D: "planning parks").
167                return Ok(PlanRequest::NotReady(format!(
168                    "Planning parked: after {MAX_STANDARDS_REVISION_TURNS} bounded revision \
169                     turns the plan's touch set still activates Flight Rules rules the \
170                     planner has not accounted for ({}). Narrow the touch set or address the \
171                     standards delta explicitly, then re-run the draft.",
172                    delta
173                        .iter()
174                        .map(|rule| format!("{} r{}", rule.id, rule.revision))
175                        .collect::<Vec<_>>()
176                        .join(", ")
177                )));
178            }
179            revisions += 1;
180            delivered.extend(delta.iter().map(|rule| rule.identity()));
181            let message = format!(
182                "The touch set of the plan you just emitted activates Flight Rules policy you \
183                 have not seen. These additional planning-stage rule(s) apply — the plan must \
184                 account for them before it can be offered for approval:\n\n{}\n\
185                 Revise the plan to account for every rule above (adjust milestones, features, \
186                 criteria, or the touch set itself), then output ONLY the revised plan JSON \
187                 conforming exactly to this JSON Schema — no prose before or after:\n{}",
188                current.render_delta(&delta),
189                plan_schema()
190            );
191            let reply = self.orch_turn(&message).await?;
192            let mut revised = runner::parse_report::<Plan>(&reply);
193            let mut prose = reply;
194            if revised.is_none() {
195                let retry = self.orch_turn(JSON_RETRY_MSG).await?;
196                revised = runner::parse_report::<Plan>(&retry);
197                if !retry.trim().is_empty() {
198                    prose = retry;
199                }
200            }
201            match revised {
202                Some(revised) => {
203                    // Research evidence rides the text that carried the
204                    // ACCEPTED plan, exactly like the initial turn's capture.
205                    self.pending_research = extract_research(&prose);
206                    plan = revised;
207                }
208                // A prose answer parks planning exactly like the initial
209                // turn's NotReady — the operator sees the planner's words.
210                None => return Ok(PlanRequest::NotReady(prose)),
211            }
212        }
213    }
214
215    /// Propose a REVISED plan for the not-yet-complete work of a running or
216    /// blocked mission (roadmap M2). An orchestrator turn — digest + the
217    /// current milestone/feature status + a revise-the-remainder instruction —
218    /// that returns a full [`Plan`] (completed milestones unchanged and first,
219    /// then the revised remainder). Reuses the streaming orchestrator, the
220    /// lenient JSON parse, and the [`PlanRequest`] `Ready`/`NotReady` enum
221    /// exactly like [`Self::request_plan`]; prose (the orchestrator wants to
222    /// discuss first) comes back as `NotReady`, never an error. The draft-stage
223    /// wrong-plan escalation is NOT offered on this prompt: a `wrongPlan` reply
224    /// here just fails plan parsing and degrades to `NotReady`.
225    ///
226    /// This only PROPOSES; [`Self::approve_revised_plan`] validates and applies
227    /// the subset the event vocabulary can express (see the contract note
228    /// above).
229    pub async fn request_revised_plan(&mut self) -> Result<PlanRequest> {
230        self.request_revised_plan_with_instructions("").await
231    }
232
233    pub(crate) async fn request_revised_plan_with_instructions(
234        &mut self,
235        instructions: &str,
236    ) -> Result<PlanRequest> {
237        let instructions = instructions.trim();
238        let instructions_block = if instructions.is_empty() {
239            "No extra operator instructions were supplied.".to_string()
240        } else {
241            format!("Operator revision request:\n{instructions}")
242        };
243        let mut message = format!(
244            "The mission is already underway. Propose a REVISED plan for the work that is \
245             NOT yet complete. Rules: keep every already-COMPLETE milestone exactly as it is \
246             and list those completed milestones FIRST and unchanged (same title, same \
247             features, same order); then revise the remaining milestones' features as the \
248             current situation warrants (drop features no longer needed, add features now \
249             required). Output ONLY a JSON object conforming exactly to this JSON Schema — \
250             no prose before or after:\n{}\n\n{}\n\n{}\n\n{}",
251            plan_schema(),
252            considered_alternatives_prompt_policy(&self.state.config),
253            research_prompt_policy(&self.state.config),
254            instructions_block
255        );
256        // Revised-planning is in scope for knowledge injection (D-C); lessons
257        // stay on the initial planning-seed path only.
258        if let Some(block) = self.render_knowledge_for_planning() {
259            message.push_str("\n\n");
260            message.push_str(&block);
261        }
262        let text = self.orch_turn(&message).await?;
263        if let Some(plan) = runner::parse_report::<Plan>(&text) {
264            self.pending_research = extract_research(&text);
265            return self.review_policy_preview(plan);
266        }
267        let retry = self.orch_turn(JSON_RETRY_MSG).await?;
268        match runner::parse_report::<Plan>(&retry) {
269            Some(plan) => {
270                self.pending_research = extract_research(&retry);
271                self.review_policy_preview(plan)
272            }
273            None => Ok(PlanRequest::NotReady(if retry.trim().is_empty() {
274                text
275            } else {
276                retry
277            })),
278        }
279    }
280}
281
282/// The third planner voice offered on the plan-request turn's prompt, one
283/// sentence: beside plan JSON and prose questions, the planner may escalate
284/// "I can produce a plan, but it is likely WRONG". Always planner-initiated
285/// via this exact JSON shape — never inferred from prose.
286const WRONG_PLAN_PROMPT_CHANNEL: &str = "If you can produce a plan but believe it is likely \
287     WRONG — the goal is misframed, the premise is broken, the spec is confidently off — \
288     respond with ONLY {\"wrongPlan\": \"<one-paragraph reason>\"}.";
289
290/// The bounded revision turns the Flight Rules fixed-point loop may take
291/// before planning parks (KRZ-345, design D-D): each turn delivers the exact
292/// rule delta the plan's touch set newly activates, so a planner that keeps
293/// widening its touch set cannot loop planning forever.
294const MAX_STANDARDS_REVISION_TURNS: usize = 3;
295
296/// Wire shape of the planner-initiated wrong-plan escalation reply.
297#[derive(serde::Deserialize)]
298struct WrongPlanReply {
299    #[serde(rename = "wrongPlan")]
300    wrong_plan: String,
301}
302
303/// The wrong-plan escalation, iff `text` parses (leniently, exactly like a
304/// plan reply) as JSON carrying a non-empty `wrongPlan` string. Prose that
305/// merely TALKS about the plan being wrong is never an escalation: no JSON
306/// `wrongPlan` key, no variant.
307fn parse_wrong_plan(text: &str) -> Option<String> {
308    let reply = runner::parse_report::<WrongPlanReply>(text)?;
309    let reason = reply.wrong_plan.trim();
310    if reason.is_empty() {
311        None
312    } else {
313        Some(reason.to_string())
314    }
315}
316
317/// Upsert one mission's line in the missions catalog (`missions/index.md`):
318/// `- <date> · [<id>](<id>/plan.md) — <goal>`. Newest last; a re-approval
319/// replaces the mission's existing line instead of appending a duplicate.
320pub fn upsert_mission_index(
321    existing: &str,
322    mission_id: &str,
323    goal: &str,
324    date: chrono::NaiveDate,
325) -> String {
326    const HEADER: &str = "# Kranz missions\n\nApproved plans, newest last.\n";
327    let goal = crate::scrub::truncate_chars(goal.trim(), 120).replace('\n', " ");
328    let line = format!("- {date} · [{mission_id}]({mission_id}/plan.md) — {goal}");
329    let marker = format!("[{mission_id}](");
330
331    let mut out = String::new();
332    let mut replaced = false;
333    let body = if existing.trim().is_empty() {
334        HEADER
335    } else {
336        existing
337    };
338    for l in body.lines() {
339        if l.contains(&marker) {
340            out.push_str(&line);
341            replaced = true;
342        } else {
343            out.push_str(l);
344        }
345        out.push('\n');
346    }
347    if !replaced {
348        out.push_str(&line);
349        out.push('\n');
350    }
351    out
352}
353
354/// Prompt policy asking the orchestrator to document its research for
355/// broad/expensive plans (mirrors [`considered_alternatives_prompt_policy`]'s
356/// triggers). The evidence lands in `research.md` beside `plan.md`.
357fn research_prompt_policy(cfg: &MissionConfig) -> String {
358    let mut triggers = Vec::new();
359    if cfg.considered_alternatives_feature_threshold > 0 {
360        triggers.push(format!(
361            "{}+ features",
362            cfg.considered_alternatives_feature_threshold
363        ));
364    }
365    if cfg.considered_alternatives_touch_set_threshold > 0 {
366        triggers.push(format!(
367            "{}+ touchSet patterns",
368            cfg.considered_alternatives_touch_set_threshold
369        ));
370    }
371    if cfg.considered_alternatives_high_usd_threshold > 0.0 {
372        triggers.push(format!(
373            "likely high estimate at or above ${:.2}",
374            cfg.considered_alternatives_high_usd_threshold
375        ));
376    }
377    if triggers.is_empty() {
378        return "The optional research object may be omitted.".to_string();
379    }
380    format!(
381        "Policy: if this plan crosses any large-scope trigger ({}) include a research object \
382         documenting filesRead, sources, facts (each with concise evidence: a path, command, or \
383         URL), ambiguities/stale docs found, and candidateKnowledgeUpdates (proposed docs/knowledge/ \
384         notes). Small plans may omit it.",
385        triggers.join(", ")
386    )
387}
388
389fn considered_alternatives_prompt_policy(cfg: &MissionConfig) -> String {
390    let feature_threshold = cfg.considered_alternatives_feature_threshold;
391    let touch_threshold = cfg.considered_alternatives_touch_set_threshold;
392    let cost_threshold = cfg.considered_alternatives_high_usd_threshold;
393    let mut triggers = Vec::new();
394    if feature_threshold > 0 {
395        triggers.push(format!("{feature_threshold}+ features"));
396    }
397    if touch_threshold > 0 {
398        triggers.push(format!("{touch_threshold}+ touchSet patterns"));
399    }
400    if cost_threshold > 0.0 {
401        triggers.push(format!(
402            "likely high estimate at or above ${cost_threshold:.2}"
403        ));
404    }
405    if triggers.is_empty() {
406        return "The optional consideredAlternatives field may be omitted.".to_string();
407    }
408    format!(
409        "Policy: if this plan crosses any large-scope trigger ({}) include \
410         consideredAlternatives with a non-empty chosen approach and at least two rejected \
411         approaches, each with a one-line tradeOff. Small plans may omit it.",
412        triggers.join(", ")
413    )
414}
415
416pub(crate) fn validate_considered_alternatives(
417    plan: &Plan,
418    estimate: &cost::CostEstimate,
419    cfg: &MissionConfig,
420) -> Result<()> {
421    let required = considered_alternatives_requirement(plan, estimate, cfg);
422    match (&plan.considered_alternatives, required) {
423        (None, Some(reason)) => Err(EngineError::InvalidState(format!(
424            "considered alternatives required: {reason}. Add consideredAlternatives with a \
425             chosen approach and at least two rejected approaches with tradeOff."
426        ))),
427        (Some(alternatives), _) => validate_considered_alternatives_body(alternatives),
428        (None, None) => Ok(()),
429    }
430}
431
432pub(crate) fn considered_alternatives_requirement(
433    plan: &Plan,
434    estimate: &cost::CostEstimate,
435    cfg: &MissionConfig,
436) -> Option<String> {
437    let features = plan_feature_count(plan);
438    if cfg.considered_alternatives_feature_threshold > 0
439        && features >= cfg.considered_alternatives_feature_threshold
440    {
441        return Some(format!(
442            "{features} feature(s) >= feature threshold {}",
443            cfg.considered_alternatives_feature_threshold
444        ));
445    }
446    let touch_set = plan.touch_set.len();
447    if cfg.considered_alternatives_touch_set_threshold > 0
448        && touch_set >= cfg.considered_alternatives_touch_set_threshold
449    {
450        return Some(format!(
451            "{touch_set} touchSet pattern(s) >= touchSet threshold {}",
452            cfg.considered_alternatives_touch_set_threshold
453        ));
454    }
455    if cfg.considered_alternatives_high_usd_threshold > 0.0
456        && estimate.high_usd >= cfg.considered_alternatives_high_usd_threshold
457    {
458        return Some(format!(
459            "estimated high cost ${:.2} >= cost threshold ${:.2}",
460            estimate.high_usd, cfg.considered_alternatives_high_usd_threshold
461        ));
462    }
463    None
464}
465
466fn validate_considered_alternatives_body(alternatives: &ConsideredAlternatives) -> Result<()> {
467    if alternatives.chosen.trim().is_empty() {
468        return Err(EngineError::InvalidState(
469            "consideredAlternatives.chosen must not be empty".to_string(),
470        ));
471    }
472    let valid_rejected = alternatives
473        .rejected
474        .iter()
475        .filter(|r| !r.approach.trim().is_empty() && !r.trade_off.trim().is_empty())
476        .count();
477    if valid_rejected < 2 {
478        return Err(EngineError::InvalidState(
479            "consideredAlternatives.rejected must include at least two entries with approach \
480             and tradeOff"
481                .to_string(),
482        ));
483    }
484    Ok(())
485}
486
487fn plan_feature_count(plan: &Plan) -> usize {
488    plan.milestones.iter().map(|m| m.features.len()).sum()
489}
490
491pub(crate) fn validate_revised_plan_for_gate(mission: &Mission, plan: &Plan) -> Result<()> {
492    if plan.reviewer_independence != mission.reviewer_independence {
493        return Err(EngineError::Config(
494            "revised plan cannot change the approved reviewerIndependence policy".into(),
495        ));
496    }
497    if plan.milestones.is_empty() {
498        return Err(EngineError::InvalidState(
499            "revised plan has no milestones".to_string(),
500        ));
501    }
502    if let Some(empty) = plan.milestones.iter().find(|m| m.features.is_empty()) {
503        return Err(EngineError::InvalidState(format!(
504            "revised plan milestone '{}' has no features",
505            empty.title
506        )));
507    }
508    crate::contract_controls::validate(&plan.validation_contract)?;
509    validate_contract_extends(&mission.validation_contract, &plan.validation_contract)?;
510    validate_vec_extends(
511        "commandGrants",
512        &mission.command_grants,
513        &plan.command_grants,
514    )?;
515    validate_vec_extends("touchSet", &mission.touch_set, &plan.touch_set)?;
516
517    let completed: Vec<&Milestone> = mission
518        .milestones
519        .iter()
520        .filter(|m| m.status == MilestoneStatus::Complete)
521        .collect();
522    for (i, done) in completed.iter().enumerate() {
523        let revised = plan.milestones.get(i).ok_or_else(|| {
524            EngineError::InvalidState(format!(
525                "revised plan drops completed milestone '{}' (must appear first, unchanged)",
526                done.title
527            ))
528        })?;
529        if revised.title.trim() != done.title.trim() {
530            return Err(EngineError::InvalidState(format!(
531                "revised plan milestone {} is '{}' but completed milestone '{}' must appear \
532                 there unchanged",
533                i + 1,
534                revised.title,
535                done.title
536            )));
537        }
538        if !completed_features_unchanged(done, revised) {
539            return Err(EngineError::InvalidState(format!(
540                "revised plan alters the features of completed milestone '{}'",
541                done.title
542            )));
543        }
544    }
545    Ok(())
546}
547
548fn validate_contract_extends(existing: &[Assertion], revised: &[Assertion]) -> Result<()> {
549    for old in existing {
550        let Some(new) = revised.iter().find(|a| a.id == old.id) else {
551            return Err(EngineError::InvalidState(format!(
552                "revised plan removes validation assertion '{}'",
553                old.id
554            )));
555        };
556        if old.statement != new.statement
557            || old.check != new.check
558            || old.command != new.command
559            || old.negative_control != new.negative_control
560        {
561            return Err(EngineError::InvalidState(format!(
562                "revised plan changes validation assertion '{}'",
563                old.id
564            )));
565        }
566    }
567    Ok(())
568}
569
570fn validate_vec_extends(label: &str, existing: &[String], revised: &[String]) -> Result<()> {
571    for old in existing {
572        if !revised.iter().any(|new| new == old) {
573            return Err(EngineError::InvalidState(format!(
574                "revised plan removes {label} entry '{old}'"
575            )));
576        }
577    }
578    Ok(())
579}
580
581/// Whether a completed milestone's feature set is reproduced UNCHANGED in the
582/// revised plan milestone (roadmap M2 re-planning guard). Delegates to the
583/// reducer's canonical [`reducer::completed_features_match`] so this pre-emit
584/// gate and the reducer's fold can never disagree: if they did, the gate could
585/// accept a revision the reducer rejects, and `emit` (which appends before it
586/// folds) would leave an unfoldable event in the append-only log.
587pub(crate) fn completed_features_unchanged(done: &Milestone, revised: &PlanMilestone) -> bool {
588    reducer::completed_features_match(&done.features, &revised.features)
589}
590
591/// Normalize a feature title for matching across a re-plan (trim + lowercase):
592/// trivial editorial differences must not spuriously drop or re-add a feature.
593pub(crate) fn norm_title(title: &str) -> String {
594    title.trim().to_lowercase()
595}
596
597/// Assign `a-1..` ids to contract assertions with missing ids and
598/// de-duplicate colliding ones (unique-ish, plan §4.5).
599pub(crate) fn assign_assertion_ids(contract: &mut [Assertion]) {
600    let mut seen = std::collections::HashSet::new();
601    let mut counter = 0usize;
602    for assertion in contract.iter_mut() {
603        let id = assertion.id.trim().to_string();
604        let id = if id.is_empty() || seen.contains(&id) {
605            loop {
606                counter += 1;
607                let candidate = format!("a-{counter}");
608                if !seen.contains(&candidate) {
609                    break candidate;
610                }
611            }
612        } else {
613            id
614        };
615        seen.insert(id.clone());
616        assertion.id = id;
617    }
618}
619
620/// JSON Schema for [`Plan`] (camelCase), embedded in the request_plan turn.
621fn plan_schema() -> serde_json::Value {
622    let control_files = serde_json::json!({
623        "type": "array",
624        "minItems": 1,
625        "items": {
626            "type": "object",
627            "additionalProperties": false,
628            "required": ["path", "content"],
629            "properties": {
630                "path": { "type": "string" },
631                "content": { "type": "string" }
632            }
633        }
634    });
635    let negative_control = serde_json::json!({
636        "type": "object",
637        "additionalProperties": false,
638        "required": ["checkerFiles", "validFiles", "defectiveFiles", "expectedFailure"],
639        "properties": {
640            "checkerFiles": control_files.clone(),
641            "validFiles": control_files.clone(),
642            "defectiveFiles": control_files,
643            "expectedFailure": { "type": "string" },
644            "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 180, "default": 60 }
645        }
646    });
647    let validation_contract = serde_json::json!({
648        "type": "array",
649        "items": {
650            "type": "object",
651            "additionalProperties": false,
652            "required": ["id", "statement", "check"],
653            "properties": {
654                "id": { "type": "string" },
655                "statement": { "type": "string" },
656                "check": { "type": "string", "enum": ["command", "agent-judgement", "pty-script"] },
657                "command": { "type": "string" },
658                "negativeControl": negative_control,
659                "ptyScript": {
660                    "type": "object",
661                    "additionalProperties": false,
662                    "required": ["command", "steps"],
663                    "properties": {
664                        "command": { "type": "string" },
665                        "timeoutSecs": { "type": "integer" },
666                        "steps": {
667                            "type": "array",
668                            "items": {
669                                "type": "object",
670                                "additionalProperties": false,
671                                "required": ["op"],
672                                "properties": {
673                                    "op": { "type": "string", "enum": ["send", "expect"] },
674                                    "text": { "type": "string" },
675                                    "pattern": { "type": "string" },
676                                    "regex": { "type": "boolean" },
677                                    "timeoutMs": { "type": "integer" }
678                                }
679                            }
680                        }
681                    }
682                }
683            }
684        }
685    });
686    serde_json::json!({
687        "type": "object",
688        "additionalProperties": false,
689        "required": ["goal", "validationContract", "milestones"],
690        "properties": {
691            "goal": { "type": "string" },
692            "consideredAlternatives": {
693                "type": "object",
694                "additionalProperties": false,
695                "required": ["chosen", "rejected"],
696                "properties": {
697                    "chosen": { "type": "string" },
698                    "rejected": {
699                        "type": "array",
700                        "minItems": 2,
701                        "items": {
702                            "type": "object",
703                            "additionalProperties": false,
704                            "required": ["approach", "tradeOff"],
705                            "properties": {
706                                "approach": { "type": "string" },
707                                "tradeOff": { "type": "string" }
708                            }
709                        }
710                    }
711                }
712            },
713            "research": {
714                "type": "object",
715                "additionalProperties": false,
716                "properties": {
717                    "filesRead": { "type": "array", "items": { "type": "string" } },
718                    "sources": { "type": "array", "items": { "type": "string" } },
719                    "facts": {
720                        "type": "array",
721                        "items": {
722                            "type": "object",
723                            "additionalProperties": false,
724                            "properties": {
725                                "fact": { "type": "string" },
726                                "evidence": { "type": "string" }
727                            }
728                        }
729                    },
730                    "ambiguities": { "type": "array", "items": { "type": "string" } },
731                    "candidateKnowledgeUpdates": { "type": "array", "items": { "type": "string" } }
732                }
733            },
734            "validationContract": validation_contract,
735            "milestones": {
736                "type": "array",
737                "minItems": 1,
738                "items": {
739                    "type": "object",
740                    "additionalProperties": false,
741                    "required": ["title", "features"],
742                    "properties": {
743                        "title": { "type": "string" },
744                        "features": {
745                            "type": "array",
746                            "minItems": 1,
747                            "items": {
748                                "type": "object",
749                                "additionalProperties": false,
750                                "required": ["title", "spec", "validationCriteria"],
751                                "properties": {
752                                    "title": { "type": "string" },
753                                    "spec": { "type": "string" },
754                                    "validationCriteria": {
755                                        "type": "array",
756                                        "items": { "type": "string" }
757                                    }
758                                }
759                            }
760                        }
761                    }
762                }
763            },
764            "commandGrants": {
765                "type": "array",
766                "items": { "type": "string" }
767            },
768            "touchSet": {
769                "type": "array",
770                "items": { "type": "string" }
771            }
772        }
773    })
774}
775
776// ---------------------------------------------------------------------------
777// Unit tests for the tricky pure helpers
778// ---------------------------------------------------------------------------
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[test]
785    fn reviewed_plan_identity_binds_work_permissions_and_reviewer_policy() {
786        let value = serde_json::json!({
787            "goal": "ship it", "validationContract": [], "milestones": []
788        });
789        let plan: Plan = serde_json::from_value(value.clone()).unwrap();
790        let identity = plan_identity(&plan);
791        assert_eq!(identity.len(), 64);
792        assert_eq!(identity, plan_identity(&plan.clone()));
793        for (field, changed) in [
794            ("goal", serde_json::json!("ship another thing")),
795            ("commandGrants", serde_json::json!(["cargo test"])),
796            ("touchSet", serde_json::json!(["crates/"])),
797            (
798                "reviewerIndependence",
799                serde_json::json!({"scrutiny": true, "functional": false}),
800            ),
801            (
802                "validationContract",
803                serde_json::json!([{
804                    "id": "a-1", "statement": "new requirement", "check": "agent-judgement"
805                }]),
806            ),
807        ] {
808            let mut changed_plan = value.clone();
809            changed_plan[field] = changed;
810            let changed_plan: Plan = serde_json::from_value(changed_plan).unwrap();
811            assert_ne!(
812                identity,
813                plan_identity(&changed_plan),
814                "identity must bind {field}"
815            );
816        }
817    }
818
819    fn assertion(id: &str) -> Assertion {
820        Assertion {
821            id: id.to_string(),
822            statement: "s".to_string(),
823            check: AssertionCheck::AgentJudgement,
824            command: None,
825            pty_script: None,
826            negative_control: None,
827        }
828    }
829
830    fn controlled_assertion() -> Assertion {
831        serde_json::from_value(serde_json::json!({
832            "id": "a-1", "statement": "reject the defect", "check": "command", "command": "sh check.sh",
833            "negativeControl": {
834                "checkerFiles": [{"path": "check.sh", "content": "check"}],
835                "validFiles": [{"path": "src/value.txt", "content": "valid"}],
836                "defectiveFiles": [{"path": "src/value.txt", "content": "defect"}],
837                "expectedFailure": "wrong-value"
838            }
839        }))
840        .unwrap()
841    }
842
843    #[test]
844    fn negative_control_schema_is_optional_and_carries_reviewable_fixtures() {
845        let schema = plan_schema();
846        let item = &schema["properties"]["validationContract"]["items"];
847        assert!(!item["required"]
848            .as_array()
849            .unwrap()
850            .contains(&serde_json::json!("negativeControl")));
851        let spec = &item["properties"]["negativeControl"];
852        for group in ["checkerFiles", "validFiles", "defectiveFiles"] {
853            assert!(spec["required"]
854                .as_array()
855                .unwrap()
856                .contains(&serde_json::json!(group)));
857            assert_eq!(
858                spec["properties"][group]["items"]["required"],
859                serde_json::json!(["path", "content"])
860            );
861        }
862        assert_eq!(spec["properties"]["timeoutSeconds"]["default"], 60);
863        assert_eq!(spec["properties"]["timeoutSeconds"]["maximum"], 180);
864        let controlled = controlled_assertion();
865        assert_eq!(
866            controlled
867                .negative_control
868                .as_ref()
869                .unwrap()
870                .timeout_seconds,
871            60
872        );
873        crate::contract_controls::validate(&[controlled]).unwrap();
874        assert!(serde_json::to_value(assertion("old"))
875            .unwrap()
876            .get("negativeControl")
877            .is_none());
878    }
879
880    #[test]
881    fn negative_control_revision_pins_every_approved_control_field() {
882        let old = controlled_assertion();
883        validate_contract_extends(std::slice::from_ref(&old), std::slice::from_ref(&old)).unwrap();
884        let mutations = [
885            (
886                "checkerFiles",
887                serde_json::json!([{"path": "check.sh", "content": "changed checker"}]),
888            ),
889            (
890                "validFiles",
891                serde_json::json!([{"path": "src/value.txt", "content": "different valid"}]),
892            ),
893            (
894                "defectiveFiles",
895                serde_json::json!([{"path": "src/value.txt", "content": "other defect"}]),
896            ),
897            ("expectedFailure", serde_json::json!("another-defect")),
898            ("timeoutSeconds", serde_json::json!(90)),
899        ];
900        for (field, value) in mutations {
901            let mut changed = serde_json::to_value(&old).unwrap();
902            changed["negativeControl"][field] = value;
903            let changed: Assertion = serde_json::from_value(changed).unwrap();
904            assert!(
905                validate_contract_extends(std::slice::from_ref(&old), &[changed]).is_err(),
906                "changed {field}"
907            );
908        }
909        let mut removed = old.clone();
910        removed.negative_control = None;
911        assert!(validate_contract_extends(
912            std::slice::from_ref(&old),
913            std::slice::from_ref(&removed)
914        )
915        .is_err());
916        assert!(
917            validate_contract_extends(&[removed], &[old]).is_err(),
918            "adding a control to an existing assertion changes its approved identity"
919        );
920    }
921
922    #[test]
923    fn assign_assertion_ids_fills_missing_and_dedupes() {
924        let mut contract = vec![
925            assertion(""),
926            assertion("x"),
927            assertion("x"),
928            assertion("a-2"),
929        ];
930        assign_assertion_ids(&mut contract);
931        let ids: Vec<&str> = contract.iter().map(|a| a.id.as_str()).collect();
932        assert_eq!(ids[0], "a-1", "missing id gets a-1");
933        assert_eq!(ids[1], "x", "explicit unique id is kept");
934        assert_ne!(ids[2], "x", "duplicate must be renamed");
935        let unique: std::collections::HashSet<&&str> = ids.iter().collect();
936        assert_eq!(unique.len(), 4, "all ids unique: {ids:?}");
937    }
938
939    #[test]
940    fn plan_schema_matches_plan_shape() {
941        // A Plan serialized to JSON uses exactly the keys the schema names.
942        let plan = Plan {
943            goal: "g".into(),
944            validation_contract: vec![Assertion {
945                id: "a-1".into(),
946                statement: "s".into(),
947                check: AssertionCheck::Command,
948                command: Some("true".into()),
949                pty_script: None,
950                negative_control: None,
951            }],
952            milestones: vec![PlanMilestone {
953                title: "m".into(),
954                features: vec![PlanFeature {
955                    title: "f".into(),
956                    spec: "s".into(),
957                    validation_criteria: vec!["c".into()],
958                }],
959            }],
960            considered_alternatives: Some(ConsideredAlternatives {
961                chosen: "single safe slice".into(),
962                rejected: vec![
963                    RejectedAlternative {
964                        approach: "big bang".into(),
965                        trade_off: "too broad".into(),
966                    },
967                    RejectedAlternative {
968                        approach: "docs only".into(),
969                        trade_off: "does not deliver behavior".into(),
970                    },
971                ],
972            }),
973            command_grants: vec!["gc lint".into()],
974            touch_set: vec!["src/**".into()],
975            // The planner-facing schema deliberately has no standardsManifest
976            // key: the pin is engine-authored at approval (KRZ-342 D-E), and
977            // `None` keeps the serialized shape (and this test) unchanged.
978            standards_manifest: None,
979            reviewer_independence: None,
980        };
981        let value = serde_json::to_value(&plan).unwrap();
982        let schema = plan_schema();
983        let props = schema["properties"].as_object().unwrap();
984        for key in value.as_object().unwrap().keys() {
985            assert!(
986                props.contains_key(key),
987                "schema missing top-level key {key}"
988            );
989        }
990    }
991}