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 pair_expectation = serde_json::json!({"oneOf": [
636        {"type": "object", "additionalProperties": false, "required": ["outcome"],
637         "properties": {"outcome": {"const": "passed"}}},
638        {"type": "object", "additionalProperties": false, "required": ["outcome", "failureId"],
639         "properties": {"outcome": {"const": "failed"}, "failureId": {"type": "string", "minLength": 1, "maxLength": 128}}},
640        {"type": "object", "additionalProperties": false, "required": ["outcome", "failureId", "diagnostic"],
641         "properties": {"outcome": {"const": "diagnostic"}, "failureId": {"type": "string", "minLength": 1, "maxLength": 128},
642            "diagnostic": {"type": "string", "minLength": 1, "maxLength": 1024}}}
643    ]});
644    let negative_control = serde_json::json!({
645        "type": "object",
646        "additionalProperties": false,
647        "required": ["checkerFiles", "validFiles", "defectiveFiles", "expectedFailure"],
648        "properties": {
649            "checkerFiles": control_files.clone(),
650            "validFiles": control_files.clone(),
651            "defectiveFiles": control_files,
652            "expectedFailure": { "type": "string" },
653            "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 180, "default": 60 },
654            "baselinePair": {
655                "type": "object", "additionalProperties": false,
656                "required": ["baselineRevision", "expectedBaseline", "expectedCandidate", "environmentLabel", "overlayCheckerOnBaseline"],
657                "properties": {
658                    "baselineRevision": {"type": "string", "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$"},
659                    "expectedBaseline": pair_expectation.clone(), "expectedCandidate": pair_expectation,
660                    "environmentLabel": {"type": "string", "minLength": 1, "maxLength": 256},
661                    "overlayCheckerOnBaseline": {"type": "boolean"}
662                }
663            }
664        }
665    });
666    let validation_contract = serde_json::json!({
667        "type": "array",
668        "items": {
669            "type": "object",
670            "additionalProperties": false,
671            "required": ["id", "statement", "check"],
672            "properties": {
673                "id": { "type": "string" },
674                "statement": { "type": "string" },
675                "check": { "type": "string", "enum": ["command", "agent-judgement", "pty-script"] },
676                "command": { "type": "string" },
677                "negativeControl": negative_control,
678                "ptyScript": {
679                    "type": "object",
680                    "additionalProperties": false,
681                    "required": ["command", "steps"],
682                    "properties": {
683                        "command": { "type": "string" },
684                        "timeoutSecs": { "type": "integer" },
685                        "steps": {
686                            "type": "array",
687                            "items": {
688                                "type": "object",
689                                "additionalProperties": false,
690                                "required": ["op"],
691                                "properties": {
692                                    "op": { "type": "string", "enum": ["send", "expect"] },
693                                    "text": { "type": "string" },
694                                    "pattern": { "type": "string" },
695                                    "regex": { "type": "boolean" },
696                                    "timeoutMs": { "type": "integer" }
697                                }
698                            }
699                        }
700                    }
701                }
702            }
703        }
704    });
705    serde_json::json!({
706        "type": "object",
707        "additionalProperties": false,
708        "required": ["goal", "validationContract", "milestones"],
709        "properties": {
710            "goal": { "type": "string" },
711            "consideredAlternatives": {
712                "type": "object",
713                "additionalProperties": false,
714                "required": ["chosen", "rejected"],
715                "properties": {
716                    "chosen": { "type": "string" },
717                    "rejected": {
718                        "type": "array",
719                        "minItems": 2,
720                        "items": {
721                            "type": "object",
722                            "additionalProperties": false,
723                            "required": ["approach", "tradeOff"],
724                            "properties": {
725                                "approach": { "type": "string" },
726                                "tradeOff": { "type": "string" }
727                            }
728                        }
729                    }
730                }
731            },
732            "research": {
733                "type": "object",
734                "additionalProperties": false,
735                "properties": {
736                    "filesRead": { "type": "array", "items": { "type": "string" } },
737                    "sources": { "type": "array", "items": { "type": "string" } },
738                    "facts": {
739                        "type": "array",
740                        "items": {
741                            "type": "object",
742                            "additionalProperties": false,
743                            "properties": {
744                                "fact": { "type": "string" },
745                                "evidence": { "type": "string" }
746                            }
747                        }
748                    },
749                    "ambiguities": { "type": "array", "items": { "type": "string" } },
750                    "candidateKnowledgeUpdates": { "type": "array", "items": { "type": "string" } }
751                }
752            },
753            "validationContract": validation_contract,
754            "milestones": {
755                "type": "array",
756                "minItems": 1,
757                "items": {
758                    "type": "object",
759                    "additionalProperties": false,
760                    "required": ["title", "features"],
761                    "properties": {
762                        "title": { "type": "string" },
763                        "features": {
764                            "type": "array",
765                            "minItems": 1,
766                            "items": {
767                                "type": "object",
768                                "additionalProperties": false,
769                                "required": ["title", "spec", "validationCriteria"],
770                                "properties": {
771                                    "title": { "type": "string" },
772                                    "spec": { "type": "string" },
773                                    "validationCriteria": {
774                                        "type": "array",
775                                        "items": { "type": "string" }
776                                    }
777                                }
778                            }
779                        }
780                    }
781                }
782            },
783            "commandGrants": {
784                "type": "array",
785                "items": { "type": "string" }
786            },
787            "touchSet": {
788                "type": "array",
789                "items": { "type": "string" }
790            }
791        }
792    })
793}
794
795// ---------------------------------------------------------------------------
796// Unit tests for the tricky pure helpers
797// ---------------------------------------------------------------------------
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802
803    #[test]
804    fn reviewed_plan_identity_binds_work_permissions_and_reviewer_policy() {
805        let value = serde_json::json!({
806            "goal": "ship it", "validationContract": [], "milestones": []
807        });
808        let plan: Plan = serde_json::from_value(value.clone()).unwrap();
809        let identity = plan_identity(&plan);
810        assert_eq!(identity.len(), 64);
811        assert_eq!(identity, plan_identity(&plan.clone()));
812        for (field, changed) in [
813            ("goal", serde_json::json!("ship another thing")),
814            ("commandGrants", serde_json::json!(["cargo test"])),
815            ("touchSet", serde_json::json!(["crates/"])),
816            (
817                "reviewerIndependence",
818                serde_json::json!({"scrutiny": true, "functional": false}),
819            ),
820            (
821                "validationContract",
822                serde_json::json!([{
823                    "id": "a-1", "statement": "new requirement", "check": "agent-judgement"
824                }]),
825            ),
826        ] {
827            let mut changed_plan = value.clone();
828            changed_plan[field] = changed;
829            let changed_plan: Plan = serde_json::from_value(changed_plan).unwrap();
830            assert_ne!(
831                identity,
832                plan_identity(&changed_plan),
833                "identity must bind {field}"
834            );
835        }
836    }
837
838    fn assertion(id: &str) -> Assertion {
839        Assertion {
840            id: id.to_string(),
841            statement: "s".to_string(),
842            check: AssertionCheck::AgentJudgement,
843            command: None,
844            pty_script: None,
845            negative_control: None,
846        }
847    }
848
849    fn controlled_assertion() -> Assertion {
850        serde_json::from_value(serde_json::json!({
851            "id": "a-1", "statement": "reject the defect", "check": "command", "command": "sh check.sh",
852            "negativeControl": {
853                "checkerFiles": [{"path": "check.sh", "content": "check"}],
854                "validFiles": [{"path": "src/value.txt", "content": "valid"}],
855                "defectiveFiles": [{"path": "src/value.txt", "content": "defect"}],
856                "expectedFailure": "wrong-value"
857            }
858        }))
859        .unwrap()
860    }
861
862    #[test]
863    fn negative_control_schema_is_optional_and_carries_reviewable_fixtures() {
864        let schema = plan_schema();
865        let item = &schema["properties"]["validationContract"]["items"];
866        assert!(!item["required"]
867            .as_array()
868            .unwrap()
869            .contains(&serde_json::json!("negativeControl")));
870        let spec = &item["properties"]["negativeControl"];
871        for group in ["checkerFiles", "validFiles", "defectiveFiles"] {
872            assert!(spec["required"]
873                .as_array()
874                .unwrap()
875                .contains(&serde_json::json!(group)));
876            assert_eq!(
877                spec["properties"][group]["items"]["required"],
878                serde_json::json!(["path", "content"])
879            );
880        }
881        assert_eq!(spec["properties"]["timeoutSeconds"]["default"], 60);
882        assert_eq!(spec["properties"]["timeoutSeconds"]["maximum"], 180);
883        let controlled = controlled_assertion();
884        assert_eq!(
885            controlled
886                .negative_control
887                .as_ref()
888                .unwrap()
889                .timeout_seconds,
890            60
891        );
892        crate::contract_controls::validate(&[controlled]).unwrap();
893        assert!(serde_json::to_value(assertion("old"))
894            .unwrap()
895            .get("negativeControl")
896            .is_none());
897    }
898
899    #[test]
900    fn negative_control_revision_pins_every_approved_control_field() {
901        let old = controlled_assertion();
902        validate_contract_extends(std::slice::from_ref(&old), std::slice::from_ref(&old)).unwrap();
903        let mutations = [
904            (
905                "checkerFiles",
906                serde_json::json!([{"path": "check.sh", "content": "changed checker"}]),
907            ),
908            (
909                "validFiles",
910                serde_json::json!([{"path": "src/value.txt", "content": "different valid"}]),
911            ),
912            (
913                "defectiveFiles",
914                serde_json::json!([{"path": "src/value.txt", "content": "other defect"}]),
915            ),
916            ("expectedFailure", serde_json::json!("another-defect")),
917            ("timeoutSeconds", serde_json::json!(90)),
918        ];
919        for (field, value) in mutations {
920            let mut changed = serde_json::to_value(&old).unwrap();
921            changed["negativeControl"][field] = value;
922            let changed: Assertion = serde_json::from_value(changed).unwrap();
923            assert!(
924                validate_contract_extends(std::slice::from_ref(&old), &[changed]).is_err(),
925                "changed {field}"
926            );
927        }
928        let mut removed = old.clone();
929        removed.negative_control = None;
930        assert!(validate_contract_extends(
931            std::slice::from_ref(&old),
932            std::slice::from_ref(&removed)
933        )
934        .is_err());
935        assert!(
936            validate_contract_extends(&[removed], &[old]).is_err(),
937            "adding a control to an existing assertion changes its approved identity"
938        );
939    }
940
941    #[test]
942    fn assign_assertion_ids_fills_missing_and_dedupes() {
943        let mut contract = vec![
944            assertion(""),
945            assertion("x"),
946            assertion("x"),
947            assertion("a-2"),
948        ];
949        assign_assertion_ids(&mut contract);
950        let ids: Vec<&str> = contract.iter().map(|a| a.id.as_str()).collect();
951        assert_eq!(ids[0], "a-1", "missing id gets a-1");
952        assert_eq!(ids[1], "x", "explicit unique id is kept");
953        assert_ne!(ids[2], "x", "duplicate must be renamed");
954        let unique: std::collections::HashSet<&&str> = ids.iter().collect();
955        assert_eq!(unique.len(), 4, "all ids unique: {ids:?}");
956    }
957
958    #[test]
959    fn plan_schema_matches_plan_shape() {
960        // A Plan serialized to JSON uses exactly the keys the schema names.
961        let plan = Plan {
962            goal: "g".into(),
963            validation_contract: vec![Assertion {
964                id: "a-1".into(),
965                statement: "s".into(),
966                check: AssertionCheck::Command,
967                command: Some("true".into()),
968                pty_script: None,
969                negative_control: None,
970            }],
971            milestones: vec![PlanMilestone {
972                title: "m".into(),
973                features: vec![PlanFeature {
974                    title: "f".into(),
975                    spec: "s".into(),
976                    validation_criteria: vec!["c".into()],
977                }],
978            }],
979            considered_alternatives: Some(ConsideredAlternatives {
980                chosen: "single safe slice".into(),
981                rejected: vec![
982                    RejectedAlternative {
983                        approach: "big bang".into(),
984                        trade_off: "too broad".into(),
985                    },
986                    RejectedAlternative {
987                        approach: "docs only".into(),
988                        trade_off: "does not deliver behavior".into(),
989                    },
990                ],
991            }),
992            command_grants: vec!["gc lint".into()],
993            touch_set: vec!["src/**".into()],
994            // The planner-facing schema deliberately has no standardsManifest
995            // key: the pin is engine-authored at approval (KRZ-342 D-E), and
996            // `None` keeps the serialized shape (and this test) unchanged.
997            standards_manifest: None,
998            reviewer_independence: None,
999        };
1000        let value = serde_json::to_value(&plan).unwrap();
1001        let schema = plan_schema();
1002        let props = schema["properties"].as_object().unwrap();
1003        for key in value.as_object().unwrap().keys() {
1004            assert!(
1005                props.contains_key(key),
1006                "schema missing top-level key {key}"
1007            );
1008        }
1009    }
1010}