use std::collections::BTreeMap;
use oneagentgraph::config::ConfigRef;
use onevcs::registry::{RepoType, Workflow};
use onevcs::releases::TargetName;
use onevcs::{Adoption, MergePolicy};
use serde::{Deserialize, Serialize};
pub const PLAN_SCHEMA_VERSION: u32 = 3;
pub const PLAN_SCHEMA_VERSIONS_READ: [u32; 3] = [PLAN_SCHEMA_VERSION, 2, 1];
pub(crate) const TITLE_IS_REQUIRED: &str = "a lifecycle node states the title its change request \
opens under, and this one names no `title`";
pub(crate) fn body_is_newer(declared: u32) -> String {
format!(
"`body` is a schema {PLAN_SCHEMA_VERSION} field and this plan declares schema_version \
{declared} — set `schema_version: {PLAN_SCHEMA_VERSION}`"
)
}
pub const PLANNER_CONTEXT_HEADING: &str = "## Planner context";
pub const AMENDMENT_HEADING: &str = "## Amendment";
const AMENDMENT_PRECEDENCE: &str =
"Where this section and the operational notes below disagree, this section wins.";
const ADDITIONAL_INFO_HEADING: &str = "## Additional info";
pub const CROSS_REPO_REFERENCES_HEADING: &str = "## Cross-repository references";
const CROSS_REPO_REFERENCES_PREAMBLE: &str =
"This node launched under fast adoption: the work it depends on is finished but has no\n\
release yet. Pin against the git references below rather than against a version. Do\n\
not change a shared interface unilaterally — propose it and keep building against the\n\
agreed surface. When these releases arrive you will be sent a note naming the\n\
versions; move the pin then.";
pub(crate) const DONE_WHEN_RETIRED: &str =
"`done_when` is no longer a plan field. A node's review bar is the \
`## Acceptance criteria` section of its own task, which the judge is handed \
verbatim; a bar broader than one node belongs in the onejudge base config the \
node-scope graph's worker already points at, under `user.done_when`";
pub(crate) const VERIFY_VIA_CI_RETIRED: &str =
"`verify_via_ci` is no longer a plan field, and nothing ever read it. The \
host's own required checks are the merge-path verification of a node whose \
`merge_policy` is `change-auto`, which watches them to their conclusion; a \
check that concludes red settles the node `checks-failed` and a bound that \
elapses with one still pending settles it `checks-unsettled`";
const RETIRED_FIELDS: &[(&str, &str)] = &[
(DONE_WHEN, DONE_WHEN_RETIRED),
(VERIFY_VIA_CI, VERIFY_VIA_CI_RETIRED),
];
const DONE_WHEN: &str = "done_when";
const VERIFY_VIA_CI: &str = "verify_via_ci";
pub(crate) const NO_GOAL: &str = "(no goal stated)";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plan {
pub schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<Goal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default = "default_concurrency")]
pub concurrency: u32,
pub tasks: Vec<Node>,
}
fn default_concurrency() -> u32 {
4
}
pub(crate) fn retired_field_refusal(document: &serde_json::Value) -> Option<String> {
match document {
serde_json::Value::Object(map) => {
if let Some((_, retired)) = RETIRED_FIELDS
.iter()
.find(|(field, _)| map.contains_key(*field))
{
let whose = map
.get("id")
.and_then(serde_json::Value::as_str)
.map(|id| format!("'{id}': "))
.unwrap_or_default();
return Some(format!("{whose}{retired}"));
}
map.values().find_map(retired_field_refusal)
}
serde_json::Value::Array(items) => items.iter().find_map(retired_field_refusal),
_ => None,
}
}
impl Node {
pub fn rendered_task(&self) -> String {
self.rendered_task_with(&[])
}
pub fn rendered_task_with(&self, references: &[CrossRepoReference]) -> String {
render_task(
self.task.as_deref().unwrap_or_default(),
self.amendment.as_deref(),
self.context.as_deref(),
references,
)
}
}
impl Step {
pub fn rendered_task(&self, node_context: Option<&str>) -> String {
self.rendered_task_with(node_context, &[])
}
pub fn rendered_task_with(
&self,
node_context: Option<&str>,
references: &[CrossRepoReference],
) -> String {
render_task(
self.task.as_deref().unwrap_or_default(),
None,
node_context,
references,
)
}
pub fn rendered_task_for(&self, node: &Node, references: &[CrossRepoReference]) -> String {
render_task(
self.task.as_deref().unwrap_or_default(),
node.amendment.as_deref(),
node.context.as_deref(),
references,
)
}
}
fn render_task(
task: &str,
amendment: Option<&str>,
context: Option<&str>,
references: &[CrossRepoReference],
) -> String {
let task = match amendment.map(str::trim).filter(|text| !text.is_empty()) {
None => task.to_string(),
Some(text) => amended(task, text),
};
let task = task.as_str();
let mut rendered = match context.map(str::trim).filter(|note| !note.is_empty()) {
None => task.to_string(),
Some(note) => format!(
"{}\n\n{PLANNER_CONTEXT_HEADING}\n\
This reports observed state and adds no acceptance criteria.\n\n{note}\n",
task.trim_end()
),
};
if references.is_empty() {
return rendered;
}
rendered = format!(
"{}\n\n{CROSS_REPO_REFERENCES_HEADING}\n\n{CROSS_REPO_REFERENCES_PREAMBLE}\n\n\
| dependency | repository | branch | commit | release target |\n\
| --- | --- | --- | --- | --- |\n",
rendered.trim_end()
);
for reference in references {
rendered.push_str(&reference.row());
rendered.push('\n');
}
rendered
}
fn amended(task: &str, amendment: &str) -> String {
let block = format!("{AMENDMENT_HEADING}\n{AMENDMENT_PRECEDENCE}\n\n{amendment}\n");
match additional_info_at(task) {
Some(at) => format!("{}\n\n{block}\n{}", task[..at].trim_end(), &task[at..]),
None => format!("{}\n\n{block}", task.trim_end()),
}
}
fn additional_info_at(task: &str) -> Option<usize> {
let mut at = 0;
for line in task.split_inclusive('\n') {
if line.trim_end() == ADDITIONAL_INFO_HEADING {
return Some(at);
}
at += line.len();
}
None
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Goal {
pub text: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeKind {
#[default]
Agent,
Human,
}
impl NodeKind {
fn is_agent(&self) -> bool {
matches!(self, Self::Agent)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Node {
pub id: String,
#[serde(default, skip_serializing_if = "NodeKind::is_agent")]
pub kind: NodeKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deps: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_turns: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub expects_no_diff: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amendment: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub parked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub executor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_graph: Option<ConfigRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_type: Option<RepoType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow: Option<Workflow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_policy: Option<MergePolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_checkout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steps: Option<Vec<Step>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume: Option<Resume>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adoption: Option<Adoption>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub consumes: BTreeMap<String, TargetName>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Step {
pub id: String,
#[serde(default, skip_serializing_if = "NodeKind::is_agent")]
pub kind: NodeKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deps: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_turns: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub expects_no_diff: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub executor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_graph: Option<ConfigRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Resume {
pub branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub completed_steps: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CrossRepoReference {
pub dependency: String,
pub repository: String,
pub branch: String,
pub commit: String,
pub release_target: String,
}
impl CrossRepoReference {
fn row(&self) -> String {
format!(
"| {} | {} | {} | {} | {} |",
cell(&self.dependency),
cell(&self.repository),
cell(&self.branch),
cell(&self.commit),
cell(&self.release_target),
)
}
}
fn cell(value: &str) -> String {
let mut rendered = String::with_capacity(value.len());
for character in value.chars() {
match character {
'|' => rendered.push_str("\\|"),
_ if character.is_control() || character.is_whitespace() => rendered.push(' '),
_ => rendered.push(character),
}
}
rendered
}
#[cfg(test)]
mod tests {
use super::*;
const SCHEMA: &str = include_str!("plan.rs");
fn documentation_of(field: &str) -> String {
let declaration = format!("pub {field}:");
let mut lines: Vec<&str> = Vec::new();
for line in SCHEMA.lines() {
let line = line.trim();
if line.starts_with("///") {
lines.push(line.trim_start_matches("///").trim());
continue;
}
if line.starts_with(&declaration) {
return lines.join(" ");
}
if !line.starts_with('#') {
lines.clear();
}
}
String::new()
}
#[test]
fn the_schema_says_what_branch_and_base_branch_mean_for_a_lifecycle_node() {
let branch = documentation_of("branch");
for claim in ["Where the work goes", "continued"] {
assert!(
branch.contains(claim),
"`branch` no longer documents '{claim}', which is what tells a planner \
that pinning it is the whole of how work already on a branch is \
continued: {branch}"
);
}
let base = documentation_of("base_branch");
for claim in [
"integration target",
"compared against",
"not a supported way to continue an existing branch",
"refuses",
] {
assert!(
base.contains(claim),
"`base_branch`'s documentation no longer states '{claim}', which is what \
stops a planner writing `base_branch` equal to `branch` and reading the \
refusal it earns as a verdict about the work: {base}"
);
}
}
#[test]
fn a_planner_note_renders_as_its_own_section_and_disclaims_itself() {
let node = Node {
id: "build".into(),
persona: Some("engineer".into()),
task: Some("## What\nship it".into()),
context: Some("the fixture moved to tests/data".into()),
..Node::default()
};
let rendered = node.rendered_task();
assert!(rendered.starts_with("## What\nship it"), "{rendered}");
assert!(rendered.contains(PLANNER_CONTEXT_HEADING), "{rendered}");
assert!(
rendered.contains("adds no acceptance criteria"),
"{rendered}"
);
assert!(
rendered.contains("the fixture moved to tests/data"),
"{rendered}"
);
}
#[test]
fn an_amendment_renders_above_the_operational_notes_and_states_its_authority() {
let node = Node {
id: "build".into(),
persona: Some("engineer".into()),
task: Some(
"## What\nship it\n\n## Acceptance criteria\n\n- it ships\n\n\
## Additional info\n\nRun the gate once, over the finished tree.\n"
.into(),
),
amendment: Some("The four comment lines are out of scope: leave them.".into()),
..Node::default()
};
let rendered = node.rendered_task();
let at = |needle: &str| {
rendered
.find(needle)
.unwrap_or_else(|| panic!("{needle} is not in:\n{rendered}"))
};
assert!(
at("## Acceptance criteria") < at(AMENDMENT_HEADING)
&& at(AMENDMENT_HEADING) < at("## Additional info"),
"the amendment is not immediately above the operational notes:\n{rendered}"
);
assert!(
rendered.contains(
"Where this section and the operational notes below disagree, this section wins."
),
"{rendered}"
);
assert!(
rendered.contains("The four comment lines are out of scope: leave them."),
"{rendered}"
);
assert!(
!rendered.contains("adds no acceptance criteria"),
"the amendment disclaimed itself:\n{rendered}"
);
assert!(
rendered.contains("Run the gate once, over the finished tree."),
"{rendered}"
);
}
#[test]
fn an_amendment_lands_at_the_end_of_a_task_that_states_no_operational_notes() {
let mut node = Node {
id: "build".into(),
persona: Some("engineer".into()),
task: Some("## What\nship it".into()),
..Node::default()
};
assert_eq!(node.rendered_task(), "## What\nship it");
node.amendment = Some("Leave the comments.".into());
assert_eq!(
node.rendered_task(),
"## What\nship it\n\n\
## Amendment\n\
Where this section and the operational notes below disagree, this section wins.\n\n\
Leave the comments.\n"
);
node.amendment = Some(" \n".into());
assert_eq!(node.rendered_task(), "## What\nship it");
node.amendment = Some("Leave the comments.".into());
node.task = Some("## What\nput it under ## Additional info when you write one".into());
let rendered = node.rendered_task();
assert!(
rendered.trim_end().ends_with("Leave the comments."),
"prose naming the heading was read as the section:\n{rendered}"
);
}
#[test]
fn a_node_carrying_both_levers_renders_each_under_its_own_heading() {
let node = Node {
id: "build".into(),
persona: Some("engineer".into()),
task: Some("## What\nship it\n\n## Additional info\n\nrun the gate.\n".into()),
amendment: Some("Leave the comments.".into()),
context: Some("the fixture moved to tests/data".into()),
..Node::default()
};
let rendered = node.rendered_task();
let at = |needle: &str| rendered.find(needle).expect("it is rendered");
assert!(
at(AMENDMENT_HEADING) < at("## Additional info")
&& at("## Additional info") < at(PLANNER_CONTEXT_HEADING),
"{rendered}"
);
assert!(
rendered.contains("adds no acceptance criteria"),
"{rendered}"
);
assert!(rendered.contains("this section wins"), "{rendered}");
}
#[test]
fn a_step_renders_the_amendment_of_the_node_it_belongs_to() {
let step = Step {
id: "implement".into(),
task: Some("## What\nimplement\n\n## Additional info\n\nnotes.\n".into()),
..Step::default()
};
let node = Node {
id: "service".into(),
amendment: Some("Leave the comments.".into()),
context: Some("the API moved".into()),
..Node::default()
};
let rendered = step.rendered_task_for(&node, &[]);
assert!(rendered.contains("Leave the comments."), "{rendered}");
assert!(rendered.contains("this section wins"), "{rendered}");
assert!(rendered.contains("the API moved"), "{rendered}");
assert!(
rendered.find(AMENDMENT_HEADING) < rendered.find("## Additional info"),
"{rendered}"
);
let older = step.rendered_task_with(node.context.as_deref(), &[]);
assert!(!older.contains(AMENDMENT_HEADING), "{older}");
assert_eq!(older, step.rendered_task(node.context.as_deref()));
}
#[test]
fn out_of_repository_dependencies_render_as_a_table_under_their_own_heading() {
let node = Node {
id: "consumer".into(),
persona: Some("engineer".into()),
task: Some("## What\nship it".into()),
..Node::default()
};
let references = vec![
CrossRepoReference {
dependency: "onevcs-release-targets".into(),
repository: "github.com/nickderobertis/onevcs".into(),
branch: "onevcs-release-targets".into(),
commit: "9f3c1ab".into(),
release_target: "crate".into(),
},
CrossRepoReference {
dependency: "packager".into(),
repository: "github.com/nickderobertis/other".into(),
..CrossRepoReference::default()
},
];
assert_eq!(
node.rendered_task_with(&references),
"## What\nship it\n\n\
## Cross-repository references\n\n\
This node launched under fast adoption: the work it depends on is finished but has \
no\nrelease yet. Pin against the git references below rather than against a version. \
Do\nnot change a shared interface unilaterally — propose it and keep building \
against the\nagreed surface. When these releases arrive you will be sent a note \
naming the\nversions; move the pin then.\n\n\
| dependency | repository | branch | commit | release target |\n\
| --- | --- | --- | --- | --- |\n\
| onevcs-release-targets | github.com/nickderobertis/onevcs | \
onevcs-release-targets | 9f3c1ab | crate |\n\
| packager | github.com/nickderobertis/other | | | |\n"
);
assert_eq!(
node.rendered_task_with(&[]),
node.rendered_task(),
"a node with no out-of-repository dependency did not render what it always rendered"
);
let forged = CrossRepoReference {
dependency: "dep".into(),
repository: "github.com/owner/a|b".into(),
branch: "topic\n| forged | row | here | now |".into(),
..CrossRepoReference::default()
};
let rendered = node.rendered_task_with(&[forged]);
let rows: Vec<&str> = rendered
.lines()
.filter(|line| line.starts_with("| dep |"))
.collect();
assert_eq!(rows.len(), 1, "a cell forged a second row:\n{rendered}");
assert_eq!(
rows[0],
"| dep | github.com/owner/a\\|b | topic \\| forged \\| row \\| here \\| now \\| | | |",
"a cell was not escaped"
);
assert_eq!(
rendered
.lines()
.filter(|line| line.starts_with('|'))
.count(),
3,
"the table is not a header, a separator, and one row:\n{rendered}"
);
let noted = Node {
context: Some("the earlier round already landed the schema".into()),
..node
};
let rendered = noted.rendered_task_with(&references);
assert!(
rendered.find(PLANNER_CONTEXT_HEADING) < rendered.find(CROSS_REPO_REFERENCES_HEADING),
"{rendered}"
);
assert!(
rendered.contains("adds no acceptance criteria"),
"{rendered}"
);
let step = Step {
id: "implement".into(),
task: Some("## What\nimplement".into()),
..Step::default()
};
assert!(step
.rendered_task_with(None, &references)
.contains(CROSS_REPO_REFERENCES_HEADING));
assert_eq!(step.rendered_task_with(None, &[]), step.rendered_task(None));
}
#[test]
fn the_adoption_fields_round_trip_and_never_appear_in_a_plan_that_omitted_them() {
let written = serde_json::json!({
"id": "consumer",
"deps": ["engine"],
"adoption": "published",
"consumes": {"engine": "crate"}
});
let node: Node = serde_json::from_value(written.clone()).expect("both fields load");
assert_eq!(node.adoption, Some(Adoption::Published));
assert_eq!(
node.consumes.get("engine").map(ToString::to_string),
Some("crate".to_string())
);
assert_eq!(serde_json::to_value(&node).expect("serializes"), written);
let bare = serde_json::json!({"id": "solo"});
let node: Node = serde_json::from_value(bare.clone()).expect("a node naming neither loads");
assert_eq!(node.adoption, None);
assert!(node.consumes.is_empty());
assert_eq!(serde_json::to_value(&node).expect("serializes"), bare);
serde_json::from_value::<Node>(serde_json::json!({"id": "x", "adoption": "eventually"}))
.expect_err("an undeclared adoption mode is refused");
serde_json::from_value::<Node>(
serde_json::json!({"id": "x", "consumes": {"engine": "not a target name"}}),
)
.expect_err("a target name the sibling would refuse is refused here");
}
#[test]
fn a_node_with_no_note_renders_its_task_unchanged() {
let node = Node {
id: "build".into(),
task: Some("## What\nship it".into()),
..Node::default()
};
assert_eq!(node.rendered_task(), "## What\nship it");
let blank = Node {
context: Some(" ".into()),
..node
};
assert_eq!(blank.rendered_task(), "## What\nship it");
}
#[test]
fn a_workstreams_note_reaches_every_agent_step() {
let step = Step {
id: "implement".into(),
persona: Some("engineer".into()),
task: Some("## What\nimplement".into()),
..Step::default()
};
let rendered = step.rendered_task(Some("the API moved"));
assert!(rendered.contains(PLANNER_CONTEXT_HEADING), "{rendered}");
assert_eq!(step.rendered_task(None), "## What\nimplement");
}
#[test]
fn a_plan_round_trips_without_growing_the_fields_it_omitted() {
let source = r#"{"schema_version":2,"tasks":[{"id":"a","persona":"e","task":"t"}]}"#;
let plan: Plan = serde_json::from_str(source).expect("it parses");
let written = serde_json::to_string(&plan).expect("it serialises");
assert!(
!written.contains("\"kind\""),
"{written} grew a default kind"
);
assert!(
!written.contains("\"deps\""),
"{written} grew an empty deps"
);
assert!(
!written.contains("\"parked\""),
"{written} grew a false parked"
);
}
#[test]
fn a_current_version_plan_round_trips_and_omits_the_budget_it_does_not_declare() {
let source = format!(
r#"{{"schema_version":{PLAN_SCHEMA_VERSION},"name":"round-trip","tasks":[
{{"id":"budgeted","persona":"e","task":"t","max_turns":45}},
{{"id":"plain","persona":"e","task":"t"}}]}}"#
);
let plan: Plan = serde_json::from_str(&source).expect("it parses");
assert_eq!(plan.schema_version, PLAN_SCHEMA_VERSION);
assert_eq!(plan.tasks[0].max_turns, Some(45));
assert_eq!(plan.tasks[1].max_turns, None);
let written = serde_json::to_string(&plan).expect("it serialises");
assert!(
written.contains(&format!("\"schema_version\":{PLAN_SCHEMA_VERSION}")),
"the version a reader decides by is not on the wire: {written}"
);
assert_eq!(
written.matches("\"max_turns\"").count(),
1,
"a node that declared no turn budget was written one: {written}"
);
assert!(written.contains("\"max_turns\":45"), "{written}");
assert_eq!(
serde_json::from_str::<Plan>(&written).expect("it re-parses"),
plan,
"the plan did not survive a round trip through this crate"
);
}
#[test]
fn a_document_carrying_a_retired_field_is_answered_about_the_field_and_names_the_node() {
for (field, expected, extra) in [
(DONE_WHEN, DONE_WHEN_RETIRED, "## Acceptance criteria"),
(VERIFY_VIA_CI, VERIFY_VIA_CI_RETIRED, "change-auto"),
] {
let document = serde_json::json!({
"schema_version": PLAN_SCHEMA_VERSION,
"tasks": [{"id": "contract", "persona": "e", "task": "t", field: true}],
});
let message = retired_field_refusal(&document).expect("the field is refused");
assert!(message.contains("'contract':"), "{message}");
assert!(message.contains(expected), "{message}");
assert!(message.contains(extra), "{message}");
}
let prose = serde_json::json!({
"tasks": [{"id": "a", "task": "do not use done_when or verify_via_ci"}],
});
assert_eq!(retired_field_refusal(&prose), None);
}
}