diff --git a/crates/registry-coordinator/src/authoring.rs b/crates/registry-coordinator/src/authoring.rs index 0ca94368b1..cb5d207c38 100644 --- a/crates/registry-coordinator/src/authoring.rs +++ b/crates/registry-coordinator/src/authoring.rs @@ -562,6 +562,9 @@ enum AuthoredStep { choose: AuthoredMapping, /// Explicit label-to-Step edges; no implicit fall-through exists. cases: BTreeMap, + /// The everyday case for readers and diagram tools; it has no execution or revision effect. + #[serde(default)] + expected: Option, /// An optional human title; it changes neither stable identity nor behavior revision. #[serde(default, deserialize_with = "optional_display_text")] #[cfg_attr(feature = "schema", schemars(with = "Option"))] @@ -967,7 +970,28 @@ fn project_integrity_diagnostics( )); } for (id, step) in &project.workflow.steps { - if let AuthoredStep::Choose { cases, .. } = step { + if let AuthoredStep::Choose { + cases, expected, .. + } = step + { + if let Some(expected) = expected + .as_ref() + .filter(|label| !cases.contains_key(*label)) + { + errors.push(document.diagnostic_at_value( + Severity::Error, + "coordinator.definition.expected-case", + &format!("/workflow/steps/{}/expected", id.as_str()), + &format!( + "expected case {} is not declared in this step's cases", + expected.as_str() + ), + &format!( + "Name one of this step's cases ({}), or omit expected.", + definition::listed(cases.keys().map(LocalId::as_str)) + ), + )); + } errors.extend(collisions( document, "coordinator.project.choose-case-id-collision", @@ -1459,6 +1483,12 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { }; for (id, step) in project.workflow.steps { let id = id.into_string(); + let expected = match &step { + AuthoredStep::Choose { expected, .. } => { + expected.as_ref().map(LocalId::as_str).map(str::to_owned) + } + _ => None, + }; let (step, title, description) = match step { AuthoredStep::WaitUntil { wait_until, @@ -1528,6 +1558,7 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { cases, title, description, + .. } => ( Step::Choose { choose: choose.into(), @@ -1553,10 +1584,12 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { description, ), }; - if title.is_some() || description.is_some() { - presentation - .steps - .insert(id.clone(), json!({"title":title,"description":description})); + if title.is_some() || description.is_some() || expected.is_some() { + let mut metadata = json!({"title":title,"description":description}); + if let Some(expected) = expected { + metadata["expected"] = json!(expected); + } + presentation.steps.insert(id.clone(), metadata); } steps.insert(id, step); } @@ -1665,6 +1698,11 @@ fn reference_sites(document: &Document) -> BTreeMap { Some(format!("/workflow/steps/{id}")) } + ("expected", Some(label)) + if !schema && path.starts_with("/workflow/steps/") => + { + Some(format!("{path}/cases/{label}")) + } ("start", Some(id)) if !schema && path == "/workflow" => { Some(format!("/workflow/steps/{id}")) } diff --git a/crates/registry-coordinator/src/contracts.rs b/crates/registry-coordinator/src/contracts.rs index 856f2ac4b3..b4d0d5dd17 100644 --- a/crates/registry-coordinator/src/contracts.rs +++ b/crates/registry-coordinator/src/contracts.rs @@ -853,10 +853,10 @@ pub fn contract(operation: Operation) -> &'static OperationContract { .map(|(id, (mut input, mut output))| { derived::finish(&mut input, &format!("{id} input")); derived::finish(&mut output, &format!("{id} output")); - let declared_refusal = crate::operations::descriptor(id) - .expect("every derived contract names a catalog operation") - .declared_refusal - .then(derived::output::); + let declared_refusal = derived_declared_refusal( + crate::operations::descriptor(id) + .expect("every derived contract names a catalog operation"), + ); ( id.to_owned(), OperationContract::new(input, output, declared_refusal, None) @@ -911,6 +911,28 @@ fn derived_contracts() -> BTreeMap<&'static str, (Value, Value)> { crate::operations::operation_catalog!(schema_catalog) } +/// The finalized declared-refusal schema for one catalog operation. +#[cfg(feature = "schema")] +fn derived_declared_refusal(descriptor: &crate::operations::OperationDescriptor) -> Option { + if !descriptor.declared_refusal { + return None; + } + let mut refusal = derived::output::(); + let title = if descriptor.id == Operation::InvokeBregAction.as_str() { + "invoke-breg-action declared-refusal output".to_owned() + } else { + format!("{} declared-refusal output", descriptor.id) + }; + if descriptor.id == Operation::LookupRecord.as_str() { + refusal["properties"]["refusalCode"] = serde_json::json!({ + "type": "string", + "const": crate::record_read::UNRESOLVED + }); + } + derived::finish(&mut refusal, &title); + Some(refusal) +} + /// Generated contract files and their reference, keyed by file name. #[cfg(feature = "schema")] pub fn generate() -> Result, serde_json::Error> { @@ -961,22 +983,9 @@ pub fn generate() -> Result, serde_json::Error> { text, ); for descriptor in crate::operations::descriptors() { - if !descriptor.declared_refusal { + let Some(refusal) = derived_declared_refusal(descriptor) else { continue; - } - let mut refusal = derived::output::(); - let title = if descriptor.id == Operation::InvokeBregAction.as_str() { - "invoke-breg-action declared-refusal output".to_owned() - } else { - format!("{} declared-refusal output", descriptor.id) }; - if descriptor.id == Operation::LookupRecord.as_str() { - refusal["properties"]["refusalCode"] = serde_json::json!({ - "type": "string", - "const": crate::record_read::UNRESOLVED - }); - } - derived::finish(&mut refusal, &title); let mut text = serde_json::to_string_pretty(&refusal)?; text.push('\n'); files.insert( @@ -2335,6 +2344,23 @@ mod tests { ); } + #[cfg(feature = "schema")] + #[test] + fn in_memory_declared_refusals_match_the_generated_catalog() { + let files = generate().unwrap(); + let catalog: BTreeMap = + serde_json::from_str(&files["catalog.json"]).unwrap(); + for descriptor in crate::operations::descriptors() { + let operation = Operation::parse(descriptor.id).unwrap(); + assert_eq!( + contract(operation).declared_refusal.as_ref(), + catalog[descriptor.id].get("declaredRefusal"), + "{}", + descriptor.id + ); + } + } + #[cfg(feature = "schema")] #[test] fn committed_contracts_match_their_generator() { diff --git a/crates/registry-coordinator/src/definition.rs b/crates/registry-coordinator/src/definition.rs index 70a9251c75..68c22dffff 100644 --- a/crates/registry-coordinator/src/definition.rs +++ b/crates/registry-coordinator/src/definition.rs @@ -873,7 +873,18 @@ impl Definition { item["onTimeout"] = serde_json::json!(on_timeout); item["onCancel"] = serde_json::json!(on_cancel); } - Step::Choose { cases, .. } => item["cases"] = serde_json::json!(cases), + Step::Choose { cases, .. } => { + item["cases"] = serde_json::json!(cases); + if let Some(expected) = self + .workflow + .presentation + .steps + .get(id) + .and_then(|metadata| metadata.get("expected")) + { + item["expected"] = expected.clone(); + } + } Step::Finish { finish, .. } => item["outcome"] = serde_json::json!(finish), _ => {} } diff --git a/crates/registry-coordinator/tests/authored_project.rs b/crates/registry-coordinator/tests/authored_project.rs index fea77bddd6..a84cc591fe 100644 --- a/crates/registry-coordinator/tests/authored_project.rs +++ b/crates/registry-coordinator/tests/authored_project.rs @@ -709,7 +709,10 @@ fn project_schema_matches_local_keys_bounds_and_foreign_schema_members() { .iter() .map(|variant| variant["properties"]["type"]["const"].as_str().unwrap()) .collect::>(); - assert_eq!(tags, ["function", "fields", "input", "step", "literal"]); + assert_eq!( + tags, + ["function", "fields", "compare", "input", "step", "literal"] + ); let function = variants .iter() .find(|variant| variant["properties"]["type"]["const"] == "function") diff --git a/crates/registry-coordinator/tests/choose_expected.rs b/crates/registry-coordinator/tests/choose_expected.rs new file mode 100644 index 0000000000..8b1dc98b00 --- /dev/null +++ b/crates/registry-coordinator/tests/choose_expected.rs @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: Apache-2.0 +//! A choose step may name the case an author expects readers to notice. The +//! hint is checked presentation metadata: it survives a snapshot and explain, +//! but does not change the graph, evaluation, scenarios or semantic revision. + +use registry_coordinator::{authoring, definition::Definition, CoordinatorError}; +use serde_json::{json, Value}; +use std::{collections::BTreeMap, fs, path::Path, process::Command}; + +fn enum_project(expected: Option) -> Value { + let mut project = json!({ + "apiVersion": "id.registrystack.org/formats/coordinator/project/v1alpha1", + "kind": "CoordinatorProject", + "project": {"id": "expected-enum", "version": "1"}, + "workflow": { + "input": { + "type": "object", + "additionalProperties": false, + "required": ["decision"], + "properties": {"decision": {"type": "string", "enum": ["approved", "rejected"]}} + }, + "deadlineSeconds": 60, + "start": "route", + "steps": { + "route": { + "type": "choose", + "choose": {"type": "input", "path": "/decision"}, + "cases": {"approved": "approved", "rejected": "rejected"} + }, + "approved": {"type": "finish", "outcome": "approved"}, + "rejected": {"type": "finish", "outcome": "rejected"} + }, + "outcomes": {"approved": {"type": "null"}, "rejected": {"type": "null"}} + } + }); + if let Some(expected) = expected { + project["workflow"]["steps"]["route"]["expected"] = expected; + } + project +} + +fn compare_project(expected: Option) -> Value { + let mut project = json!({ + "apiVersion": "id.registrystack.org/formats/coordinator/project/v1alpha1", + "kind": "CoordinatorProject", + "project": {"id": "expected-compare", "version": "1"}, + "workflow": { + "input": { + "type": "object", + "additionalProperties": false, + "required": ["score"], + "properties": {"score": {"type": "number"}} + }, + "deadlineSeconds": 60, + "start": "route", + "steps": { + "route": { + "type": "choose", + "choose": { + "type": "compare", + "all": [{"value": {"type": "input", "path": "/score"}, "greater": 5}] + }, + "cases": {"matched": "high", "unmatched": "low"} + }, + "high": {"type": "finish", "outcome": "high"}, + "low": {"type": "finish", "outcome": "low"} + }, + "outcomes": {"high": {"type": "null"}, "low": {"type": "null"}} + } + }); + if let Some(expected) = expected { + project["workflow"]["steps"]["route"]["expected"] = expected; + } + project +} + +fn write_project(root: &Path, project: &Value) { + fs::write( + root.join("workflow.yaml"), + serde_norway::to_string(project).unwrap(), + ) + .unwrap(); +} + +fn load(project: &Value) -> registry_coordinator::Result { + let root = tempfile::tempdir().unwrap(); + write_project(root.path(), project); + Definition::load(root.path()) +} + +fn refused(project: &Value) -> CoordinatorError { + match load(project) { + Ok(_) => panic!("the project must be refused"), + Err(error) => error, + } +} + +fn parse_refused(project: &Value) -> CoordinatorError { + let source = serde_norway::to_string(project).unwrap(); + match authoring::parse_project(Path::new("workflow.yaml"), source) { + Ok(_) => panic!("the project must be refused"), + Err(error) => error, + } +} + +fn explained_step<'a>(explain: &'a Value, id: &str) -> &'a Value { + explain["steps"] + .as_array() + .unwrap() + .iter() + .find(|step| step["id"] == id) + .unwrap_or_else(|| panic!("no explained step {id}: {explain}")) +} + +#[test] +fn expected_is_snapshot_presentation_without_execution_or_revision_meaning() { + let legacy = load(&compare_project(None)).unwrap(); + let mut annotated = compare_project(Some(json!("unmatched"))); + annotated["workflow"]["steps"]["route"]["title"] = json!("Classify score"); + annotated["workflow"]["steps"]["route"]["description"] = + json!("Routes scores above five to high."); + let expected = load(&annotated).unwrap(); + let mut changed = annotated.clone(); + changed["workflow"]["steps"]["route"]["expected"] = json!("matched"); + let changed = load(&changed).unwrap(); + let mut removed = annotated.clone(); + removed["workflow"]["steps"]["route"] + .as_object_mut() + .unwrap() + .remove("expected"); + let removed = load(&removed).unwrap(); + + assert_eq!(expected.digest, legacy.digest); + assert_eq!(changed.digest, legacy.digest); + assert_eq!(removed.digest, legacy.digest); + for definition in [&expected, &changed, &removed] { + let metadata = &definition.workflow.presentation.steps["route"]; + assert_eq!(metadata["title"], "Classify score"); + assert_eq!(metadata["description"], "Routes scores above five to high."); + } + assert_eq!( + changed.workflow.presentation.steps["route"]["expected"], + "matched" + ); + assert!(removed.workflow.presentation.steps["route"] + .get("expected") + .is_none()); + for (input, choice) in [ + (json!({"score": 8}), "matched"), + (json!({"score": 2}), "unmatched"), + ] { + assert_eq!( + expected + .evaluate("route", &input, &BTreeMap::new()) + .unwrap(), + json!(choice) + ); + assert_eq!( + expected + .evaluate("route", &input, &BTreeMap::new()) + .unwrap(), + legacy.evaluate("route", &input, &BTreeMap::new()).unwrap() + ); + } + + let legacy_explain = legacy.explain().unwrap(); + let expected_explain = expected.explain().unwrap(); + assert_eq!( + legacy_explain["steps"] + .as_array() + .unwrap() + .iter() + .map(|step| &step["id"]) + .collect::>(), + expected_explain["steps"] + .as_array() + .unwrap() + .iter() + .map(|step| &step["id"]) + .collect::>() + ); + let legacy_route = explained_step(&legacy_explain, "route"); + let expected_route = explained_step(&expected_explain, "route"); + assert_eq!(expected_route["next"], legacy_route["next"]); + assert_eq!(expected_route["cases"], legacy_route["cases"]); + assert!(legacy_route.get("expected").is_none()); + assert_eq!(expected_route["expected"], "unmatched"); + assert_eq!( + expected_explain["references"]["/workflow/steps/route/expected"]["target"], + "/workflow/steps/route/cases/unmatched" + ); + + let snapshot = expected.snapshot().unwrap(); + let frozen: Value = serde_json::from_str(&snapshot).unwrap(); + assert_eq!( + frozen["workflow"]["presentation"]["steps"]["route"]["expected"], + "unmatched" + ); + let restored = Definition::from_snapshot(&snapshot).unwrap(); + assert_eq!(restored.digest, legacy.digest); + assert_eq!( + explained_step(&restored.explain().unwrap(), "route")["expected"], + "unmatched" + ); +} + +#[cfg(feature = "schema")] +#[test] +fn only_the_choose_schema_has_an_optional_expected_local_id() { + let schema: Value = serde_json::from_str(&authoring::project_schema().unwrap()).unwrap(); + let variants = schema["$defs"]["AuthoredStep"]["oneOf"].as_array().unwrap(); + let choose = variants + .iter() + .find(|variant| variant["properties"]["type"]["const"] == "choose") + .expect("the choose step schema"); + assert_eq!(choose["properties"]["expected"]["$ref"], "#/$defs/LocalId"); + assert!(!choose["required"] + .as_array() + .unwrap() + .contains(&json!("expected"))); + assert!(variants.iter().all(|variant| { + variant["properties"]["type"]["const"] == "choose" + || variant["properties"].get("expected").is_none() + })); +} + +#[test] +fn any_declared_string_case_can_be_the_expected_case() { + let definition = load(&enum_project(Some(json!("approved")))).unwrap(); + assert_eq!( + definition + .evaluate("route", &json!({"decision": "approved"}), &BTreeMap::new(),) + .unwrap(), + "approved" + ); + assert_eq!( + definition + .evaluate("route", &json!({"decision": "rejected"}), &BTreeMap::new(),) + .unwrap(), + "rejected" + ); + assert_eq!( + explained_step(&definition.explain().unwrap(), "route")["expected"], + "approved" + ); +} + +#[test] +fn expected_must_name_a_declared_case_with_a_located_repair() { + let error = refused(&enum_project(Some(json!("missing")))); + let finding = error + .diagnostics + .iter() + .find(|finding| finding.path == "/workflow/steps/route/expected") + .unwrap_or_else(|| panic!("no expected-case finding: {:#?}", error.diagnostics)); + assert!( + finding.message.contains("case") || finding.suggested_action.contains("case"), + "{finding:#?}" + ); + assert!(!finding.suggested_action.is_empty(), "{finding:#?}"); + let source = finding.source.as_ref().expect("a source location"); + assert!(source.file.ends_with("workflow.yaml"), "{finding:#?}"); + assert!(source.line.is_some(), "{finding:#?}"); +} + +#[test] +fn expected_is_forbidden_on_non_choose_steps() { + let mut project = compare_project(None); + project["workflow"]["steps"]["high"]["expected"] = json!("high"); + let error = parse_refused(&project); + let finding = &error.diagnostics[0]; + assert_eq!(finding.code, "config.unknown-key"); + assert_eq!(finding.path, "/workflow/steps/high/expected"); + assert!(!finding.suggested_action.is_empty()); + assert!(finding.source.as_ref().unwrap().line.is_some()); +} + +#[test] +fn expected_uses_shared_yaml_string_and_local_id_rules() { + for (value, code) in [ + (json!(7), "config.expected-string"), + (Value::Null, "config.null-value"), + (json!(""), "config.invalid-value"), + ] { + let error = parse_refused(&compare_project(Some(value))); + let finding = error + .diagnostics + .iter() + .find(|finding| finding.path == "/workflow/steps/route/expected") + .unwrap_or_else(|| panic!("no expected finding: {:#?}", error.diagnostics)); + assert_eq!(finding.code, code, "{finding:#?}"); + assert!(!finding.suggested_action.is_empty(), "{finding:#?}"); + assert!( + finding.source.as_ref().unwrap().line.is_some(), + "{finding:#?}" + ); + } +} + +#[test] +fn json_check_explain_and_offline_scenarios_preserve_both_branches() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + write_project(&root, &compare_project(Some(json!("unmatched")))); + + let checked = Command::new(env!("CARGO_BIN_EXE_coordinatorctl")) + .args([ + "--format", + "json", + "check", + "--project", + root.to_str().unwrap(), + "--explain", + ]) + .output() + .unwrap(); + assert!( + checked.status.success(), + "{}", + String::from_utf8_lossy(&checked.stdout) + ); + let explain: Value = serde_json::from_slice(&checked.stdout).unwrap(); + assert_eq!(explained_step(&explain, "route")["expected"], "unmatched"); + + let scenarios = json!({ + "apiVersion": "id.registrystack.org/formats/coordinator/scenarios/v1alpha1", + "kind": "CoordinatorScenarios", + "cases": [ + { + "id": "matched-branch", + "admittedAt": "2026-10-10T09:00:00Z", + "input": {"score": 8}, + "replies": {}, + "expect": { + "path": ["route", "high"], + "state": "completed", + "outcome": {"type": "named", "value": "high"} + } + }, + { + "id": "unmatched-branch", + "admittedAt": "2026-10-10T09:00:00Z", + "input": {"score": 2}, + "replies": {}, + "expect": { + "path": ["route", "low"], + "state": "completed", + "outcome": {"type": "named", "value": "low"} + } + } + ] + }); + fs::write( + root.join("scenarios.yaml"), + serde_norway::to_string(&scenarios).unwrap(), + ) + .unwrap(); + let tested = Command::new(env!("CARGO_BIN_EXE_coordinatorctl")) + .args([ + "--format", + "json", + "test", + "--project", + root.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + tested.status.success(), + "{}", + String::from_utf8_lossy(&tested.stdout) + ); + let report: Value = serde_json::from_slice(&tested.stdout).unwrap(); + assert_eq!(report["status"], "passed"); + assert_eq!(report["passed"], 2); + assert_eq!(report["failed"], 0); + assert_eq!(report["scenarios"][0]["path"], json!(["route", "high"])); + assert_eq!(report["scenarios"][1]["path"], json!(["route", "low"])); +} diff --git a/products/coordinator/PRODUCT-CONTRACT.md b/products/coordinator/PRODUCT-CONTRACT.md index f75cea3bda..e11ed049b1 100644 --- a/products/coordinator/PRODUCT-CONTRACT.md +++ b/products/coordinator/PRODUCT-CONTRACT.md @@ -69,6 +69,12 @@ at the first decisive one, so `present: true` before a numeric test guards an op missing or wrongly typed value in any other test fails the run with `choice-invalid`, as a missing case does today; it never routes silently to `unmatched`. Compare holds no Function and no clock. +A `choose` step may declare `expected: ` to identify its everyday path for readers +and diagram tools. The label must name one of that step's `cases`, for both compares and mapped +strings. Omission leaves the expected path unspecified; neither case order nor YAML order implies +one. `expected` is presentation metadata: it survives snapshots and appears on the step in +`check --explain`, but changes neither routing, the order of `next`, nor `workflowRevision`. + Owner-generated numeric width annotations such as `int64` are accepted only on matching numeric types. Validation intersects explicit bounds with the declared width and the canonical safe-integer range, while retaining imported schema bytes diff --git a/products/coordinator/README.md b/products/coordinator/README.md index 7d0801d8ee..40243f6ca8 100644 --- a/products/coordinator/README.md +++ b/products/coordinator/README.md @@ -108,6 +108,28 @@ Check resolves those references locally, verifies dominance on every incoming ro mappings to input and result contracts before execution. Dynamic values are validated again at runtime before preparation or dispatch. An invalid command reports `failurePath`, never its value. +A `choose` step can name its everyday case with optional `expected` metadata: + +```yaml +check-revocation: + title: Has the licence been revoked? + type: choose + expected: unmatched + choose: + type: compare + all: + - value: {type: input, path: /revoked} + equals: true + cases: + matched: already-revoked + unmatched: record-suspension +``` + +The label must exist in that step's `cases`; it also works with cases selected by a mapped string. +`coordinatorctl --format json check --project ./my-follow-up --explain` includes `expected` on +that step for diagram tools. Omit it when no expected path is declared. It changes presentation +only, leaving routing, `next` ordering and `workflowRevision` unchanged. + ```sh coordinatorctl check --project ./my-follow-up --explain coordinatorctl test --project ./my-follow-up diff --git a/products/coordinator/generated/project/project.schema.json b/products/coordinator/generated/project/project.schema.json index 98a1a1f3a3..13518be362 100644 --- a/products/coordinator/generated/project/project.schema.json +++ b/products/coordinator/generated/project/project.schema.json @@ -539,6 +539,10 @@ "$ref": "#/$defs/DisplayText", "description": "An optional explanation for readers; it has no execution or authorization effect." }, + "expected": { + "$ref": "#/$defs/LocalId", + "description": "The everyday case for readers and diagram tools; it has no execution or revision effect." + }, "title": { "$ref": "#/$defs/DisplayText", "description": "An optional human title; it changes neither stable identity nor behavior revision." diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index ae5f1d378f..15e1915afa 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -4848,11 +4848,11 @@ "description": "Registry Coordinator portable project authoring JSON Schema.", "source": { "path": "crates/registry-coordinator/src/authoring.rs", - "sha256": "e35f753eff850412f05a1ca2fd61c65755fcbba6dfdf21fc3615c8c41a52575a" + "sha256": "ae097b5904283e2c57ae726020a6376fe02b975391df284e67e08e5b3ddaf64c" }, "artifact": { "path": "products/coordinator/generated/project/project.schema.json", - "sha256": "c000f1b2b00bd521fbce3e15aa71715b4bb9d051db8d10a1063cdfc92add9991", + "sha256": "472c4861ab27a301f077784e63cd06891ef4b73813aab1dd802c567daac70d16", "mediaType": "application/schema+json" } },