1use 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
16pub 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 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 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 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 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 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 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 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 self.pending_research = extract_research(&prose);
206 plan = revised;
207 }
208 None => return Ok(PlanRequest::NotReady(prose)),
211 }
212 }
213 }
214
215 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 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
282const 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
290const MAX_STANDARDS_REVISION_TURNS: usize = 3;
295
296#[derive(serde::Deserialize)]
298struct WrongPlanReply {
299 #[serde(rename = "wrongPlan")]
300 wrong_plan: String,
301}
302
303fn 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
317pub 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
354fn 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
581pub(crate) fn completed_features_unchanged(done: &Milestone, revised: &PlanMilestone) -> bool {
588 reducer::completed_features_match(&done.features, &revised.features)
589}
590
591pub(crate) fn norm_title(title: &str) -> String {
594 title.trim().to_lowercase()
595}
596
597pub(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
620fn 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#[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 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 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}