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 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#[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 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 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}