diff --git a/Cargo.lock b/Cargo.lock index 9526480013..bb2967c534 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6266,6 +6266,7 @@ dependencies = [ "registry-messaging-core", "registry-platform-audit", "registry-platform-buildinfo", + "registry-platform-config", "registry-platform-crypto", "registry-platform-dispatch", "registry-platform-httputil", diff --git a/crates/registry-bregctl/src/check.rs b/crates/registry-bregctl/src/check.rs index 3a352ef6f0..14a7a85667 100644 --- a/crates/registry-bregctl/src/check.rs +++ b/crates/registry-bregctl/src/check.rs @@ -189,6 +189,10 @@ pub(super) fn run( if outcome.files > 0 { outcome.report.set_files_checked(outcome.files); } + if let Some(project) = request.project { + registry_platform_config::apply_check_acceptances(project, &mut outcome.report); + outcome.files = outcome.report.files_checked().unwrap_or(outcome.files); + } let denied = request.deny_warnings && outcome.report.warning_count() > 0; let exit = if outcome.unavailable { OPERATIONAL_FAILURE_EXIT @@ -819,10 +823,12 @@ pub(super) fn compile_for_inspection( ) })?; let mut outcome = Outcome::default(); + let compiled = compile_project_with_documents(&selected, profile, &mut outcome); + registry_platform_config::apply_check_acceptances(&selected, &mut outcome.report); // A production compile can succeed while `check --production` still // refuses the project, as for a deployment identity a package must not // carry, so an error in the report refuses inspection too. - match compile_project_with_documents(&selected, profile, &mut outcome) { + match compiled { Some((compiled, documents)) if !outcome.report.has_errors() => { let authoring = documents.model_authoring(&compiled); Ok((compiled, outcome.report.into_diagnostics(), authoring)) diff --git a/crates/registry-bregctl/src/file_check.rs b/crates/registry-bregctl/src/file_check.rs index 46fffd71af..1e6c9890bd 100644 --- a/crates/registry-bregctl/src/file_check.rs +++ b/crates/registry-bregctl/src/file_check.rs @@ -36,6 +36,7 @@ pub(crate) struct Request<'a> { } /// Why a check produced no findings. +#[derive(Debug)] pub(crate) enum Failure { /// The shared reader refused the document. Refused(Report), @@ -86,7 +87,18 @@ type Check = fn(&Input<'_>) -> Result, Failure>; /// it is refused with, when the file is read as written (CFG-SEC-2); and its /// check. A file a command writes holds text it copied, which is never /// substituted, so it has no remedy. -const CHECKS: [(FormatSpec<'static>, Option<&str>, Check); 15] = [ +const CHECKS: [(FormatSpec<'static>, Option<&str>, Check); 16] = [ + ( + registry_platform_yaml::CHECK_ACCEPTANCES_FORMAT, + Some(WRITE_THE_VALUE), + |input| { + registry_platform_config::read_project_check_acceptances( + &input.file.display().to_string(), + input.bytes, + )?; + Ok(Vec::new()) + }, + ), (fixtures::JOURNEYS_FORMAT, Some(WRITE_THE_VALUE), journeys), ( fixtures::SCHEMA_TEST_RECEIPT_FORMAT, @@ -517,6 +529,26 @@ fn nearest_written_pointer<'a>(document: &Document, pointer: &'a str) -> &'a str mod tests { use super::*; + #[test] + fn shared_check_acceptances_are_a_supported_tool_file() { + let temporary = tempfile::tempdir().unwrap(); + let file = temporary + .path() + .canonicalize() + .unwrap() + .join("check-acceptances.yaml"); + std::fs::write( + &file, + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances: []\n", + ) + .unwrap(); + + let report = check(&file, &file.display().to_string(), &Against::Nothing) + .expect("the shared acceptance format is a supported tool file"); + assert!(!report.has_errors()); + assert!(report.diagnostics().is_empty()); + } + #[test] fn a_descriptor_location_is_the_last_five_path_components() { assert_eq!( diff --git a/crates/registry-bregctl/tests/cli.rs b/crates/registry-bregctl/tests/cli.rs index d8527b0edd..9ffe9760cb 100644 --- a/crates/registry-bregctl/tests/cli.rs +++ b/crates/registry-bregctl/tests/cli.rs @@ -105,6 +105,78 @@ fn duplicate_profile_display_warns_once_and_deny_warnings_refuses_it() { ]); assert_eq!(denied.status.code(), Some(1)); assert_eq!(json_stdout(&denied)["diagnostics"], report["diagnostics"]); + + fs::write( + project.path().join("check-acceptances.yaml"), + r#"apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 +kind: PlatformCheckAcceptances +acceptances: + - code: breg.access-profile.duplicate-label + scope: + file: registry.yaml + path: /accessProfiles/@id=directory-observer/display/label + reason: These profiles intentionally share a label in this reviewed project. +"#, + ) + .unwrap(); + let accepted = bregctl(&[ + "--format", + "json", + "check", + path(project.path()), + "--deny-warnings", + ]); + assert!(accepted.status.success(), "{accepted:?}"); + let accepted_report = json_stdout(&accepted); + assert_eq!(accepted_report["diagnostics"].as_array().unwrap().len(), 1); + let accepted_warning = &accepted_report["diagnostics"][0]; + assert_eq!(accepted_warning["severity"], "warning"); + assert_eq!( + accepted_warning["scope"], + json!({ + "file": "registry.yaml", + "path": "/accessProfiles/@id=directory-observer/display/label" + }) + ); + assert_eq!( + accepted_warning["acceptedReason"], + "These profiles intentionally share a label in this reviewed project." + ); + assert_eq!(accepted_warning["rank"], "hygiene"); + + let explained = bregctl(&[ + "--format", + "json", + "explain", + "access", + path(project.path()), + ]); + assert!(explained.status.success(), "{explained:?}"); + let explained_report = json_stdout(&explained); + let explained_warning = explained_report["findings"] + .as_array() + .and_then(|findings| { + findings + .iter() + .find(|finding| finding["code"] == "breg.access-profile.duplicate-label") + }) + .unwrap_or_else(|| panic!("accepted warning remains visible: {explained_report}")); + assert_eq!( + explained_warning["acceptedReason"], + "These profiles intentionally share a label in this reviewed project." + ); + assert_eq!( + explained_warning["scope"], + json!({ + "file": "registry.yaml", + "path": "/accessProfiles/@id=directory-observer/display/label" + }) + ); + + let accepted_human = bregctl(&["check", path(project.path()), "--deny-warnings"]); + assert!(accepted_human.status.success(), "{accepted_human:?}"); + assert!(String::from_utf8_lossy(&accepted_human.stdout) + .ends_with("0 errors, 0 warnings, 1 accepted warning in 2 files\n")); } #[test] @@ -1908,6 +1980,8 @@ fn assert_check_diagnostic(diagnostic: &Value, artifact: Option<&str>) { "source", "related", "rank", + "scope", + "acceptedReason", ] .contains(&key.as_str()), "{key} in {diagnostic}" @@ -2159,7 +2233,7 @@ fn facility_starter_checks_clean_with_warnings_denied() { ]); assert!(initialized.status.success(), "{initialized:?}"); - let registry = registry_platform_yaml::Reader::new("registry.yaml") + let mut registry = registry_platform_yaml::Reader::new("registry.yaml") .scan(&fs::read(destination.join("registry.yaml")).unwrap()) .unwrap() .unwrap() @@ -2199,6 +2273,75 @@ fn facility_starter_checks_clean_with_warnings_denied() { .as_array() .unwrap() .is_empty()); + + let registrar = registry["accessProfiles"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|profile| profile["id"] == "facility-registrar") + .unwrap(); + let facility_target = registrar["permissions"]["actions"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|permission| permission["action"] == "register-facility") + .unwrap()["targets"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|target| target["recordType"] == "facility") + .unwrap(); + facility_target["rowBoundaries"] = json!("unrestricted"); + fs::write( + destination.join("registry.yaml"), + serde_norway::to_string(®istry).unwrap(), + ) + .unwrap(); + + let refused = bregctl(&[ + "--format", + "json", + "check", + path(&destination), + "--deny-warnings", + ]); + assert_eq!(refused.status.code(), Some(1), "{refused:?}"); + let refused_report = json_stdout(&refused); + let findings = refused_report["diagnostics"].as_array().unwrap(); + assert_eq!(findings.len(), 1, "{refused_report}"); + let warning = &findings[0]; + assert_eq!(warning["code"], "breg.access.target-unrestricted-rows"); + assert_eq!(warning["severity"], "warning"); + assert_eq!(warning["rank"], "widens-access"); + + fs::write( + destination.join("check-acceptances.yaml"), + serde_json::to_vec_pretty(&json!({ + "apiVersion": registry_platform_yaml::CHECK_ACCEPTANCES_API_VERSION, + "kind": registry_platform_yaml::CHECK_ACCEPTANCES_KIND, + "acceptances": [{ + "code": warning["code"], + "scope": warning["scope"], + "reason": "The facility registrar intentionally has registry-wide create authority." + }] + })) + .unwrap(), + ) + .unwrap(); + let accepted = bregctl(&[ + "--format", + "json", + "check", + path(&destination), + "--deny-warnings", + ]); + assert!(accepted.status.success(), "{accepted:?}"); + let accepted_report = json_stdout(&accepted); + assert_eq!(accepted_report["diagnostics"].as_array().unwrap().len(), 1); + assert_eq!( + accepted_report["diagnostics"][0]["acceptedReason"], + "The facility registrar intentionally has registry-wide create authority." + ); } #[test] @@ -3322,6 +3465,73 @@ fn check_says_so_when_an_expression_stops_it() { } } +#[test] +fn project_acceptance_applies_after_the_runtime_warning_is_collected() { + let scratch = TestProject::asset_fixture(); + let project = scratch.path().join("runtime-warning-project"); + let initialized = bregctl(&["init", path(&project)]); + assert!(initialized.status.success(), "{initialized:?}"); + let runtime = project.join("runtime.example.yaml"); + let source = fs::read_to_string(&runtime).unwrap(); + let edited = source.replacen( + "allowedAlgorithm: EdDSA", + "allowedAlgorithm: ${BREG_CHECK_TEST_ALGORITHM}", + 1, + ); + assert_ne!(edited, source); + fs::write(&runtime, edited).unwrap(); + + let arguments = [ + "--format", + "json", + "check", + path(&project), + "--runtime-config", + path(&runtime), + ]; + let warned = bregctl(&arguments); + assert!(warned.status.success(), "{warned:?}"); + let report = json_stdout(&warned); + let warning = report["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|diagnostic| diagnostic["code"] == "platform.runtime-config.check-incomplete") + .unwrap_or_else(|| panic!("the runtime warning is reported: {report}")); + assert_eq!( + warning["scope"], + json!({ + "file": "runtime.example.yaml", + "path": "/authentication/oidc/allowedAlgorithm" + }) + ); + + fs::write( + project.join("check-acceptances.yaml"), + serde_json::to_vec_pretty(&json!({ + "apiVersion": registry_platform_yaml::CHECK_ACCEPTANCES_API_VERSION, + "kind": registry_platform_yaml::CHECK_ACCEPTANCES_KIND, + "acceptances": [{ + "code": warning["code"], + "scope": warning["scope"], + "reason": "This deployment supplies the reviewed algorithm through its environment." + }] + })) + .unwrap(), + ) + .unwrap(); + let mut strict_arguments = arguments.to_vec(); + strict_arguments.push("--deny-warnings"); + let accepted = bregctl(&strict_arguments); + assert!(accepted.status.success(), "{accepted:?}"); + let report = json_stdout(&accepted); + assert_eq!(report["diagnostics"].as_array().unwrap().len(), 1); + assert_eq!( + report["diagnostics"][0]["acceptedReason"], + "This deployment supplies the reviewed algorithm through its environment." + ); +} + /// One numbered step, read back out of the rendering: the ordinal line and the /// continuation lines hanging under it, checked for the column the renderer /// folds them into and rejoined into the sentence the step states. diff --git a/crates/registry-bregctl/tests/cli/file_check.rs b/crates/registry-bregctl/tests/cli/file_check.rs index 7c9ac95b8b..b94dcbf5c3 100644 --- a/crates/registry-bregctl/tests/cli/file_check.rs +++ b/crates/registry-bregctl/tests/cli/file_check.rs @@ -146,6 +146,37 @@ fn every_registered_tool_example_checks_without_a_diagnostic() { assert_eq!(report["diagnostics"], json!([]), "{report}"); } +#[test] +fn check_acceptance_reason_refuses_an_authored_expression() { + let scratch = scratch(); + let file = scratch + .path() + .canonicalize() + .unwrap() + .join("check-acceptances.yaml"); + fs::write( + &file, + r#"apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 +kind: PlatformCheckAcceptances +acceptances: + - code: breg.access.target-unrestricted-rows + scope: {file: registry.yaml, path: /accessProfiles/@id=reader} + reason: ${CHECK_ACCEPTANCE_REASON_CANARY} +"#, + ) + .unwrap(); + + let output = check_file(&file, &["--format", "json"]); + assert_eq!(output.status.code(), Some(1)); + let report = json_report(&output); + assert_eq!( + report["diagnostics"][0]["code"], + "config.substitution-not-allowed" + ); + assert_eq!(report["diagnostics"][0]["path"], "/acceptances/0/reason"); + assert!(!String::from_utf8_lossy(&output.stdout).contains("CHECK_ACCEPTANCE_REASON_CANARY")); +} + #[test] fn model_selection_effective_ids_refuse_separator_collisions_at_authored_sources() { let scratch = scratch(); diff --git a/crates/registry-caseworkctl/src/lib.rs b/crates/registry-caseworkctl/src/lib.rs index 12d8b1e7bb..83ac1e4fa6 100644 --- a/crates/registry-caseworkctl/src/lib.rs +++ b/crates/registry-caseworkctl/src/lib.rs @@ -492,12 +492,17 @@ fn run(command: Command) -> Result { Command::Serve(_) => unreachable!("serve replaces the ctl process before report dispatch"), Command::Init { project, template } => project::init(&project, &template), Command::Check(args) => { - let mut result = project::check(&args.project, args.production, args.deny_warnings); + let mut result = if args.runtime_config.is_some() { + project::check(&args.project, args.production, false) + } else { + project::check_with_acceptances(&args.project, args.production, args.deny_warnings) + }; if let Some(runtime) = args.runtime_config { result = project::check_runtime_config( &args.project, &runtime, args.environment, + args.deny_warnings, result, ); } @@ -696,11 +701,14 @@ fn render_human(value: &Value, out: &mut (impl Write + ?Sized)) -> io::Result<() let diagnostics: Vec = serde_json::from_value(value["diagnostics"].clone()).map_err(io::Error::other)?; if !diagnostics.is_empty() { - write!( - out, - "{}", - registry_platform_yaml::Report::new(diagnostics).render_human() - )?; + let mut report = registry_platform_yaml::Report::new(diagnostics); + if let Some(files) = value["filesChecked"] + .as_u64() + .and_then(|files| usize::try_from(files).ok()) + { + report.set_files_checked(files); + } + write!(out, "{}", report.render_human())?; if value["ok"] == false && value["command"] != "provider-add" { return Ok(()); } diff --git a/crates/registry-caseworkctl/src/project.rs b/crates/registry-caseworkctl/src/project.rs index 54d67a05c9..5eb05a4f34 100644 --- a/crates/registry-caseworkctl/src/project.rs +++ b/crates/registry-caseworkctl/src/project.rs @@ -300,6 +300,53 @@ pub(crate) fn load_and_check_policy(project: &Path) -> Result { Ok(CaseworkProject::load(project.join("casework.yaml"))?) } pub(super) fn check(project: &Path, production: bool, deny_warnings: bool) -> Result { + check_impl(project, production, deny_warnings) +} + +pub(super) fn check_with_acceptances( + project: &Path, + production: bool, + deny_warnings: bool, +) -> Result { + finish_check_with_acceptances(project, deny_warnings, check(project, production, false)) +} + +fn finish_check_with_acceptances( + project: &Path, + deny_warnings: bool, + checked: Result, +) -> Result { + let (mut report, mut checked) = match checked { + Ok(checked) => { + let mut report = Report::new(serde_json::from_value(checked["diagnostics"].clone())?); + if let Some(files) = checked["filesChecked"] + .as_u64() + .and_then(|files| usize::try_from(files).ok()) + { + report.set_files_checked(files); + } + (report, Some(checked)) + } + Err(error) => match crate::configuration_report(&error) { + Some(report) => (report.clone(), None), + None => return Err(error), + }, + }; + registry_platform_config::apply_check_acceptances(project, &mut report); + if report.has_errors() || (deny_warnings && report.warning_count() > 0) { + return Err(report.into()); + } + let mut checked = checked + .take() + .expect("an error-free project has a check report"); + checked["diagnostics"] = report.to_json_value(); + if let Some(files) = report.files_checked() { + checked["filesChecked"] = json!(files); + } + Ok(checked) +} + +fn check_impl(project: &Path, production: bool, deny_warnings: bool) -> Result { let decoded = read_project(project)?; let policy = decoded.value; let mut diagnostics = decoded.document.warnings(); @@ -942,17 +989,42 @@ pub(super) fn check_runtime_config( project: &Path, path: &Path, environment: bool, + deny_warnings: bool, checked: Result, ) -> Result { let policy = load_and_check_policy(project).ok(); let absolute = std::path::absolute(path)?; - let report = registry_casework::check_runtime(&absolute, policy.as_ref(), environment); - let report = Report::new(report.diagnostics); - if report.has_errors() { - return Err(report.into()); + let runtime = registry_casework::check_runtime(&absolute, policy.as_ref(), environment); + let runtime = Report::new(runtime.diagnostics); + let (mut combined, mut checked) = match checked { + Ok(checked) => { + let mut report = Report::new(serde_json::from_value(checked["diagnostics"].clone())?); + if let Some(files) = checked["filesChecked"] + .as_u64() + .and_then(|files| usize::try_from(files).ok()) + { + report.set_files_checked(files); + } + (report, Some(checked)) + } + Err(error) => match crate::configuration_report(&error) { + Some(report) => (report.clone(), None), + None => return Err(error), + }, + }; + combined.extend(runtime); + registry_platform_config::apply_check_acceptances(project, &mut combined); + if combined.has_errors() || (deny_warnings && combined.warning_count() > 0) { + return Err(combined.into()); } - let mut checked = checked?; + let mut checked = checked + .take() + .expect("an error-free project has a check report"); checked["runtimeConfig"] = json!(path); + checked["diagnostics"] = combined.to_json_value(); + if let Some(files) = combined.files_checked() { + checked["filesChecked"] = json!(files); + } Ok(checked) } pub(super) fn doctor(path: &Path) -> Result { @@ -1008,7 +1080,8 @@ mod display_integrity_tests { ); fs::write(&path, authored).unwrap(); - let report = check(&project, false, false).expect("warnings do not normally refuse check"); + let report = check_with_acceptances(&project, false, false) + .expect("warnings do not normally refuse check"); let warning = report["diagnostics"] .as_array() .unwrap() @@ -1019,8 +1092,10 @@ mod display_integrity_tests { warning["path"], "/reviewPolicies/decision/outcomes/rejected/label" ); + let files_without_acceptances = report["filesChecked"].as_u64().unwrap(); - let error = check(&project, false, true).expect_err("--deny-warnings refuses the warning"); + let error = check_with_acceptances(&project, false, true) + .expect_err("--deny-warnings refuses the warning"); let report = crate::configuration_report(&error).expect("a structured warning report"); assert_eq!(report.error_count(), 0); assert_eq!(report.warning_count(), 1); @@ -1028,6 +1103,65 @@ mod display_integrity_tests { report.diagnostics()[0].code, "casework.review-policy.duplicate-outcome-label" ); + + let scope = warning["scope"] + .as_object() + .expect("a stable warning scope"); + fs::write( + project.join("check-acceptances.yaml"), + format!( + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances:\n - code: casework.review-policy.duplicate-outcome-label\n scope:\n file: {}\n path: {}\n reason: The two outcomes are deliberately phrased alike for this pilot.\n", + scope["file"].as_str().unwrap(), + scope["path"].as_str().unwrap(), + ), + ) + .unwrap(); + let accepted = check_with_acceptances(&project, false, true) + .expect("an accepted warning does not make --deny-warnings fail"); + assert_eq!( + accepted["filesChecked"], + files_without_acceptances + 1, + "the present acceptance file is counted once" + ); + let warning = accepted["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|item| item["code"] == "casework.review-policy.duplicate-outcome-label") + .unwrap(); + assert_eq!( + warning["acceptedReason"], + "The two outcomes are deliberately phrased alike for this pilot." + ); + let mut human = Vec::new(); + crate::render_human(&accepted, &mut human).unwrap(); + assert!( + String::from_utf8(human) + .unwrap() + .contains(&format!("in {} files", files_without_acceptances + 1)), + "the human summary uses the JSON filesChecked count" + ); + + let mut diagnostics = + serde_json::from_value::>(accepted["diagnostics"].clone()).unwrap(); + diagnostics[0].accepted_reason = None; + let mut refused = Report::new(diagnostics); + refused.push(Diagnostic::error( + "casework.project.invalid", + "/project", + "the project has an unrelated error", + "Correct the project.", + )); + let error = finish_check_with_acceptances(&project, true, Err(refused.into())) + .expect_err("the unrelated error still refuses the check"); + let report = crate::configuration_report(&error).unwrap(); + assert_eq!(report.error_count(), 1); + assert_eq!(report.warning_count(), 0); + assert_eq!(report.accepted_warning_count(), 1); + assert_eq!( + report.diagnostics()[0].accepted_reason.as_deref(), + Some("The two outcomes are deliberately phrased alike for this pilot.") + ); } #[test] diff --git a/crates/registry-caseworkctl/src/provider_add.rs b/crates/registry-caseworkctl/src/provider_add.rs index a9c2ea54f4..ebef50aeb3 100644 --- a/crates/registry-caseworkctl/src/provider_add.rs +++ b/crates/registry-caseworkctl/src/provider_add.rs @@ -1910,6 +1910,7 @@ pub(super) fn add( &staged_project, &staged_project.join("runtime.example.yaml"), false, + false, Ok(check), )?, "caseworkctl check --runtime-config", diff --git a/crates/registry-coordinator/src/cli.rs b/crates/registry-coordinator/src/cli.rs index e2d50e9159..d32998a23d 100644 --- a/crates/registry-coordinator/src/cli.rs +++ b/crates/registry-coordinator/src/cli.rs @@ -396,6 +396,7 @@ fn check( ); report.extend(findings); exit_code = exit_code.max(project_exit); + registry_platform_config::apply_check_acceptances(path, &mut report); } if report.has_errors() || (deny_warnings && report.warning_count() > 0) { return Err(CoordinatorError::from_report(report).with_exit(exit_code)); diff --git a/crates/registry-coordinator/src/project_check.rs b/crates/registry-coordinator/src/project_check.rs index 6d61f9bb1a..23447950ab 100644 --- a/crates/registry-coordinator/src/project_check.rs +++ b/crates/registry-coordinator/src/project_check.rs @@ -185,7 +185,10 @@ pub(crate) fn check_directory( let path = entry.path(); let name = entry.file_name(); // Runtime custody belongs to deployment commands, never author checking. - if name == ".coordinator" || name == "workflow.yaml" { + if name == ".coordinator" + || name == "workflow.yaml" + || name == registry_platform_yaml::CHECK_ACCEPTANCES_FILE + { continue; } let kind = match entry.file_type() { diff --git a/crates/registry-coordinator/tests/project_checking.rs b/crates/registry-coordinator/tests/project_checking.rs index 6f9c026175..cc0a009367 100644 --- a/crates/registry-coordinator/tests/project_checking.rs +++ b/crates/registry-coordinator/tests/project_checking.rs @@ -109,6 +109,55 @@ fn project_check_reports_duplicate_display_text_and_deny_warnings_refuses_it() { denied["diagnostics"][0]["code"], "coordinator.project.duplicate-step-title" ); + + let scope = warning["scope"] + .as_object() + .expect("a stable warning scope"); + fs::write( + root.join("check-acceptances.yaml"), + format!( + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances:\n - code: coordinator.project.duplicate-step-title\n scope:\n file: {}\n path: {}\n reason: Operators distinguish these pilot steps by their surrounding stage.\n", + scope["file"].as_str().unwrap(), + scope["path"].as_str().unwrap(), + ), + ) + .unwrap(); + let (exit, accepted) = check(&root, &["--deny-warnings"]); + assert_eq!(exit, 0, "{accepted}"); + let warning = accepted["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|item| item["code"] == "coordinator.project.duplicate-step-title") + .unwrap(); + assert_eq!( + warning["acceptedReason"], + "Operators distinguish these pilot steps by their surrounding stage." + ); +} + +#[test] +fn project_check_never_accepts_an_error() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + project(&root); + let path = root.join("workflow.yaml"); + fs::write( + &path, + format!( + "unknownMember: true\n{}", + fs::read_to_string(&path).unwrap() + ), + ) + .unwrap(); + fs::write( + root.join("check-acceptances.yaml"), + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances:\n - code: config.unknown-key\n scope:\n file: workflow.yaml\n path: /unknownMember\n reason: Errors remain active even when an entry attempts to accept them.\n", + ) + .unwrap(); + let (exit, still_refused) = check(&root, &[]); + assert_eq!(exit, 1, "{still_refused}"); + assert!(still_refused["diagnostics"][0]["acceptedReason"].is_null()); } #[test] diff --git a/crates/registry-discoveryctl/src/lib.rs b/crates/registry-discoveryctl/src/lib.rs index 35892d0648..dd2e04fc14 100644 --- a/crates/registry-discoveryctl/src/lib.rs +++ b/crates/registry-discoveryctl/src/lib.rs @@ -269,6 +269,10 @@ fn check( } let mut report = Report::new(diagnostics); report.set_files_checked(files); + if let Some(root) = &request.project { + registry_platform_config::apply_check_acceptances(root, &mut report); + files = report.files_checked().unwrap_or(files); + } let exit = if unavailable { report::OPERATIONAL_FAILURE_EXIT } else if report.has_errors() || (request.deny_warnings && report.warning_count() > 0) { @@ -594,6 +598,102 @@ mod tests { assert!(unknown["suggestedAction"].is_string()); } + #[test] + fn project_check_acceptance_keeps_the_warning_and_allows_the_warning_gate() { + let root = fixture_copy(|root| { + fs::write(root.join("notes.yaml"), "title: Project notes\n").unwrap(); + }); + let args = [ + "check", + "--project", + root.path().to_str().unwrap(), + "--format", + "json", + "--deny-warnings", + ]; + let (exit, stdout, _) = run_cli(&args); + assert_eq!(exit, ExitCode::from(1)); + let before = json(&stdout); + let files_before_acceptance = before["filesChecked"].as_u64().unwrap(); + let warning = &before["diagnostics"][0]; + let acceptance = serde_json::json!({ + "apiVersion": registry_platform_yaml::CHECK_ACCEPTANCES_API_VERSION, + "kind": registry_platform_yaml::CHECK_ACCEPTANCES_KIND, + "acceptances": [{"code":warning["code"], "scope":warning["scope"], "reason":"Project notes are intentionally kept beside the origins."}] + }); + fs::write( + root.path().join("check-acceptances.yaml"), + serde_json::to_vec(&acceptance).unwrap(), + ) + .unwrap(); + let (exit, stdout, stderr) = run_cli(&args); + assert_eq!(exit, ExitCode::SUCCESS, "{stdout}{stderr}"); + let after = json(&stdout); + assert_eq!( + after["filesChecked"], + serde_json::json!(files_before_acceptance + 1) + ); + assert_eq!(after["warnings"], 0); + assert_eq!(after["diagnostics"].as_array().unwrap().len(), 1); + assert_eq!(after["diagnostics"][0]["severity"], "warning"); + assert_eq!( + after["diagnostics"][0]["acceptedReason"], + "Project notes are intentionally kept beside the origins." + ); + } + + #[test] + fn project_acceptance_is_retained_when_an_explicit_runtime_refuses() { + let root = fixture_copy(|root| { + fs::write(root.join("notes.yaml"), "title: Project notes\n").unwrap(); + }); + let deployment = root.path().join("deployment"); + fs::create_dir(&deployment).unwrap(); + let runtime = deployment.join("runtime.yaml"); + let source = fs::read_to_string(Path::new(FIXTURE).join("runtime.yaml")).unwrap(); + let source = format!("{source}unknownMember: true\n"); + fs::write(&runtime, source).unwrap(); + fs::write( + root.path().join("check-acceptances.yaml"), + serde_json::to_vec(&serde_json::json!({ + "apiVersion": registry_platform_yaml::CHECK_ACCEPTANCES_API_VERSION, + "kind": registry_platform_yaml::CHECK_ACCEPTANCES_KIND, + "acceptances": [{ + "code": "discovery.project.unread-file", + "scope": {"file": "notes.yaml", "path": ""}, + "reason": "Project notes are intentionally kept beside the origins." + }] + })) + .unwrap(), + ) + .unwrap(); + let (exit, stdout, stderr) = run_cli(&[ + "check", + "--project", + root.path().to_str().unwrap(), + "--runtime-config", + runtime.to_str().unwrap(), + "--format", + "json", + "--deny-warnings", + ]); + assert_eq!(exit, ExitCode::from(1), "{stdout}{stderr}"); + let report = json(&stdout); + assert_eq!(report["errors"], 1); + assert_eq!(report["warnings"], 0); + assert_eq!(report["diagnostics"].as_array().unwrap().len(), 2); + let accepted = report["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|diagnostic| diagnostic["code"] == "discovery.project.unread-file") + .expect("the accepted project warning remains beside the runtime error"); + assert_eq!( + accepted["acceptedReason"], + "Project notes are intentionally kept beside the origins." + ); + } + #[test] fn cfg_diag_4_deny_warnings_turns_a_warning_into_exit_one() { let root = fixture_copy(|root| { diff --git a/crates/registry-discoveryctl/src/project.rs b/crates/registry-discoveryctl/src/project.rs index 131adfa614..84f172f124 100644 --- a/crates/registry-discoveryctl/src/project.rs +++ b/crates/registry-discoveryctl/src/project.rs @@ -798,7 +798,10 @@ fn check_root_files(root: &Path, environment: bool, findings: &mut Findings) { return; }; let path = entry.path(); - if is_yaml(&path) && path.file_name() != Some(ORIGINS_FILE.as_ref()) { + if is_yaml(&path) + && path.file_name() != Some(ORIGINS_FILE.as_ref()) + && path.file_name() != Some(registry_platform_yaml::CHECK_ACCEPTANCES_FILE.as_ref()) + { paths.push(path); } } diff --git a/crates/registry-documentsctl/src/lib.rs b/crates/registry-documentsctl/src/lib.rs index 8a52cd15c6..d105d01101 100644 --- a/crates/registry-documentsctl/src/lib.rs +++ b/crates/registry-documentsctl/src/lib.rs @@ -709,6 +709,9 @@ fn check(args: &CheckArgs) -> Outcome { combined.extend(runtime.report); report["runtimeConfig"] = json!(path); } + if let Some(root) = &args.project { + registry_platform_config::apply_check_acceptances(root, &mut combined); + } report["filesChecked"] = json!(combined.files_checked().unwrap_or(0)); report["diagnostics"] = combined.to_json_value(); let exit = check_exit(&combined, args.deny_warnings, unavailable); @@ -1241,6 +1244,35 @@ mod tests { assert_eq!(report["diagnostics"], json!([])); } + #[test] + fn malformed_project_acceptances_refuse_an_otherwise_clean_check() { + let directory = scratch(); + let project = directory.path().join("project"); + let project_text = project.to_str().unwrap(); + assert_eq!(json_report(&["init", project_text]).0, ExitCode::SUCCESS); + std::fs::write( + project.join("check-acceptances.yaml"), + "apiVersion: wrong\nkind: PlatformCheckAcceptances\nacceptances: []\n", + ) + .unwrap(); + + let (code, report) = json_report(&["check", "--project", project_text]); + assert_eq!(code, ExitCode::from(DOMAIN_REFUSAL_EXIT), "{report:#}"); + assert_eq!( + report["filesChecked"], 2, + "documents.yaml and the malformed acceptance file are each counted once" + ); + assert!(report["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|item| { + item["source"]["file"] + .as_str() + .is_some_and(|file| file.ends_with("check-acceptances.yaml")) + })); + } + #[test] fn deny_warnings_turns_a_warning_into_a_refusal() { let mut warning = diff --git a/crates/registry-evidencectl/src/check.rs b/crates/registry-evidencectl/src/check.rs index f6ac94074c..db6fdacf92 100644 --- a/crates/registry-evidencectl/src/check.rs +++ b/crates/registry-evidencectl/src/check.rs @@ -39,7 +39,22 @@ pub(crate) fn check( .into()); } } - let mut checked = crate::normalized::inspect(project, target, "check")?; + let mut checked = match crate::normalized::inspect(project, target, "check") { + Ok(checked) => checked, + Err(error) => { + let Some(report) = error.downcast_ref::() else { + return Err(error); + }; + let mut report = report.clone(); + registry_platform_config::apply_check_acceptances(project, &mut report); + return Err(report.into()); + } + }; + registry_platform_config::apply_check_acceptances(project, &mut checked.diagnostics); + checked.report["diagnostics"] = checked.diagnostics.to_json_value(); + if checked.diagnostics.has_errors() { + return Err(checked.diagnostics.into()); + } if let Some(path) = target { let bytes = crate::authored::read_authored_file(path, "EvidenceTarget")?; let target_model = @@ -266,6 +281,69 @@ mod tests { report.diagnostics()[0].source.as_ref().unwrap().file, project_file.to_string_lossy() ); + + fs::write( + temporary.path().join("check-acceptances.yaml"), + r#"apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 +kind: PlatformCheckAcceptances +acceptances: + - code: evidence.project.duplicate-source-label + scope: + file: evidence.yaml + path: /sources/person-record/label + reason: These source labels are intentionally aligned for this reviewed project. +"#, + ) + .unwrap(); + let accepted = check(temporary.path(), None, false, true) + .expect("an accepted warning does not fail --deny-warnings"); + assert_eq!(accepted.diagnostics.error_count(), 0); + assert_eq!(accepted.diagnostics.warning_count(), 0); + assert_eq!(accepted.diagnostics.accepted_warning_count(), 1); + assert_eq!(accepted.diagnostics.diagnostics().len(), 1); + let warning = &accepted.diagnostics.diagnostics()[0]; + assert_eq!(warning.severity, registry_platform_yaml::Severity::Warning); + assert_eq!( + warning.scope.as_ref(), + Some(®istry_platform_yaml::CheckScope { + file: "evidence.yaml".to_owned(), + path: "/sources/person-record/label".to_owned(), + }) + ); + assert_eq!( + warning.accepted_reason.as_deref(), + Some("These source labels are intentionally aligned for this reviewed project.") + ); + assert_eq!( + accepted.report["diagnostics"][0]["acceptedReason"], + "These source labels are intentionally aligned for this reviewed project." + ); + } + + #[test] + fn malformed_check_acceptances_refuse_the_evidence_project() { + let temporary = tempfile::tempdir().unwrap(); + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/examples/standalone"); + copy_tree(&source, temporary.path()); + fs::write( + temporary.path().join("check-acceptances.yaml"), + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances: invalid\n", + ) + .unwrap(); + + let error = check(temporary.path(), None, false, false) + .expect_err("a malformed acceptance file refuses the project"); + let report = error + .downcast_ref::() + .expect("malformed acceptances produce a structured diagnostic report"); + assert!(report.has_errors()); + assert!(report.diagnostics().iter().any(|diagnostic| { + diagnostic + .source + .as_ref() + .is_some_and(|source| source.file.ends_with("check-acceptances.yaml")) + })); } #[test] fn check_preserves_duplicate_display_warning_when_function_compilation_fails() { @@ -324,6 +402,37 @@ mod tests { assert_eq!(warning_source.file, project_file.to_string_lossy()); assert!(warning_source.line.is_some()); assert!(warning_source.column.is_some()); + + fs::write( + temporary.path().join("check-acceptances.yaml"), + r#"apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 +kind: PlatformCheckAcceptances +acceptances: + - code: evidence.project.duplicate-source-label + scope: + file: evidence.yaml + path: /sources/person-record/label + reason: These source labels are intentionally aligned for this reviewed project. +"#, + ) + .unwrap(); + let error = check(temporary.path(), None, false, true) + .expect_err("the Function error still refuses a project with an accepted warning"); + let report = error + .downcast_ref::() + .expect("the mixed refusal remains a structured report"); + assert_eq!(report.error_count(), 1); + assert_eq!(report.warning_count(), 0); + assert_eq!(report.accepted_warning_count(), 1); + let warning = report + .diagnostics() + .iter() + .find(|diagnostic| diagnostic.code == "evidence.project.duplicate-source-label") + .expect("the accepted warning remains beside the error"); + assert_eq!( + warning.accepted_reason.as_deref(), + Some("These source labels are intentionally aligned for this reviewed project.") + ); } #[test] fn check_caps_project_warnings_with_a_function_compilation_error() { diff --git a/crates/registry-evidencectl/src/file_check.rs b/crates/registry-evidencectl/src/file_check.rs index 355302c101..661b3554e4 100644 --- a/crates/registry-evidencectl/src/file_check.rs +++ b/crates/registry-evidencectl/src/file_check.rs @@ -60,7 +60,15 @@ macro_rules! tooling_format { }; } -const FORMATS: [(FormatSpec<'static>, bool, Check); 8] = [ +const FORMATS: [(FormatSpec<'static>, bool, Check); 9] = [ + ( + registry_platform_yaml::CHECK_ACCEPTANCES_FORMAT, + false, + |file, bytes| { + let decoded = registry_platform_config::read_project_check_acceptances(file, bytes)?; + Ok(decoded.document.report_with(Vec::new())) + }, + ), ( registry_evidence_authoring::formats::ACCESS_CLIENT, false, @@ -323,6 +331,22 @@ fn in_file(mut diagnostic: Diagnostic, label: &str) -> Diagnostic { mod tests { use super::*; + #[test] + fn shared_check_acceptances_are_a_supported_tool_file() { + let temporary = tempfile::tempdir().unwrap(); + let file = temporary.path().join("check-acceptances.yaml"); + std::fs::write( + &file, + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances: []\n", + ) + .unwrap(); + + let report = check(&file, &file.display().to_string()) + .unwrap_or_else(|_| panic!("the shared acceptance format is a supported tool file")); + assert!(!report.has_errors()); + assert!(report.diagnostics().is_empty()); + } + #[test] fn standalone_file_warnings_are_preserved_and_obey_the_requested_policy() { let warning = || { diff --git a/crates/registry-evidencectl/tests/file_check.rs b/crates/registry-evidencectl/tests/file_check.rs index 8023dbaa1b..f5a247e498 100644 --- a/crates/registry-evidencectl/tests/file_check.rs +++ b/crates/registry-evidencectl/tests/file_check.rs @@ -100,6 +100,33 @@ fn every_registered_example_passes_in_json_output() { } } +#[test] +fn check_acceptance_reason_refuses_an_authored_expression() { + let directory = tempfile::tempdir().expect("directory"); + let file = directory.path().join("check-acceptances.yaml"); + fs::write( + &file, + r#"apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 +kind: PlatformCheckAcceptances +acceptances: + - code: evidence.project.duplicate-source-label + scope: {file: evidence.yaml, path: /sources/person-record/label} + reason: ${CHECK_ACCEPTANCE_REASON_CANARY} +"#, + ) + .unwrap(); + + let output = check_file(&file, &["--format", "json"]); + assert_eq!(output.status.code(), Some(1)); + let report = json_report(&output); + assert_eq!( + report["diagnostics"][0]["code"], + "config.substitution-not-allowed" + ); + assert_eq!(report["diagnostics"][0]["path"], "/acceptances/0/reason"); + assert!(!String::from_utf8_lossy(&output.stdout).contains("CHECK_ACCEPTANCE_REASON_CANARY")); +} + #[test] fn a_file_with_a_member_its_format_does_not_define_is_refused() { let directory = tempfile::tempdir().expect("directory"); diff --git a/crates/registry-manifest-cli/src/main.rs b/crates/registry-manifest-cli/src/main.rs index f3f4af222d..a0a9ef4ac5 100644 --- a/crates/registry-manifest-cli/src/main.rs +++ b/crates/registry-manifest-cli/src/main.rs @@ -274,6 +274,11 @@ fn validate_command(matches: &ArgMatches) -> Result { } None => None, }; + let project_root = Path::new(path) + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + registry_platform_config::apply_check_acceptances(project_root, &mut report); let exit = check_exit(&report, check.unavailable, options.deny_warnings); if options.json { let mut written = CtlReport::new("validate", exit, &report); @@ -304,7 +309,8 @@ fn validate_command(matches: &ArgMatches) -> Result { fn validate_profiles_command(matches: &ArgMatches) -> Result { let options = CheckOptions::parse(matches, "profiles")?; let root = PathBuf::from(options.path); - let check = check_profiles(&root); + let mut check = check_profiles(&root); + registry_platform_config::apply_check_acceptances(&root, &mut check.report); let exit = check_exit(&check.report, check.unavailable, options.deny_warnings); if options.json { let mut written = CtlReport::new("validate-profiles", exit, &check.report); diff --git a/crates/registry-manifest-cli/src/profile.rs b/crates/registry-manifest-cli/src/profile.rs index 96749a4bcc..68537e2203 100644 --- a/crates/registry-manifest-cli/src/profile.rs +++ b/crates/registry-manifest-cli/src/profile.rs @@ -660,6 +660,7 @@ fn manifest_codelists(manifest: &MetadataManifest) -> BTreeMap<&str, BTreeSet<&s /// Warn about every YAML file under `root` that is neither a descriptor nor /// a listed fixture. Linked directories are not followed. fn unlisted_files(findings: &mut Findings, root: &Path, known: &BTreeSet) { + let check_acceptances = normalized(&root.join(registry_platform_yaml::CHECK_ACCEPTANCES_FILE)); let mut pending = vec![root.to_path_buf()]; while let Some(directory) = pending.pop() { let Ok(entries) = fs::read_dir(&directory) else { @@ -689,7 +690,7 @@ fn unlisted_files(findings: &mut Findings, root: &Path, known: &BTreeSet (PathBuf, PathBuf) { (project, data) } +#[test] +fn project_check_accepts_an_exact_warning_without_weakening_deny_warnings() { + let (_guard, root) = workspace(); + let (project, _) = starter(&root); + let path = project.join("messaging.yaml"); + std::fs::write( + &path, + std::fs::read_to_string(&path).unwrap().replacen( + "requiredScopes: [messaging:send]", + "requiredScopes: ['*']", + 1, + ), + ) + .unwrap(); + + let (exit, report) = invoke(arguments(&[ + "check", + "--project", + project.to_str().unwrap(), + ])); + assert_eq!(exit, ExitCode::SUCCESS, "{report}"); + let warning = report["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|item| item["code"] == "messaging.project.wildcard-spelled-item") + .expect("the project checker reports the explicit wildcard warning"); + let files_without_acceptances = report["filesChecked"].as_u64().unwrap(); + let scope = warning["scope"] + .as_object() + .expect("a stable warning scope"); + + let (exit, denied) = invoke(arguments(&[ + "check", + "--project", + project.to_str().unwrap(), + "--deny-warnings", + ])); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT), "{denied}"); + + std::fs::write( + project.join("check-acceptances.yaml"), + format!( + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances:\n - code: messaging.project.wildcard-spelled-item\n scope:\n file: {}\n path: {}\n reason: This isolated pilot intentionally grants every messaging scope.\n", + scope["file"].as_str().unwrap(), + scope["path"].as_str().unwrap(), + ), + ) + .unwrap(); + let (exit, accepted) = invoke(arguments(&[ + "check", + "--project", + project.to_str().unwrap(), + "--deny-warnings", + ])); + assert_eq!(exit, ExitCode::SUCCESS, "{accepted}"); + assert_eq!( + accepted["filesChecked"], + files_without_acceptances + 1, + "the present acceptance file is counted once" + ); + let warning = accepted["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|item| item["code"] == "messaging.project.wildcard-spelled-item") + .unwrap(); + assert_eq!( + warning["acceptedReason"], + "This isolated pilot intentionally grants every messaging scope." + ); +} + +#[test] +fn combined_project_and_runtime_refusal_keeps_an_accepted_project_warning() { + let (_guard, root) = workspace(); + let (project, _) = starter(&root); + let path = project.join("messaging.yaml"); + let source = std::fs::read_to_string(&path).unwrap().replacen( + "requiredScopes: [messaging:send]", + "requiredScopes: ['*']", + 1, + ); + std::fs::write(&path, &source).unwrap(); + + let (exit, report) = invoke(arguments(&[ + "check", + "--project", + project.to_str().unwrap(), + ])); + assert_eq!(exit, ExitCode::SUCCESS, "{report}"); + let warning = report["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|item| item["code"] == "messaging.project.wildcard-spelled-item") + .expect("the project checker reports the explicit wildcard warning"); + let scope = warning["scope"] + .as_object() + .expect("a stable warning scope"); + std::fs::write( + project.join("check-acceptances.yaml"), + format!( + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances:\n - code: messaging.project.wildcard-spelled-item\n scope:\n file: {}\n path: {}\n reason: This isolated pilot intentionally grants every messaging scope.\n", + scope["file"].as_str().unwrap(), + scope["path"].as_str().unwrap(), + ), + ) + .unwrap(); + std::fs::write( + &path, + source.replacen("provider: mail-relay", "provider: unknown-provider", 1), + ) + .unwrap(); + + let missing_runtime = root.join("missing-runtime.yaml"); + let (exit, refused) = invoke(arguments(&[ + "check", + "--project", + project.to_str().unwrap(), + "--runtime-config", + missing_runtime.to_str().unwrap(), + "--deny-warnings", + ])); + assert_eq!(exit, ExitCode::from(OPERATIONAL_FAILURE_EXIT), "{refused}"); + assert!( + refused["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|item| item["severity"] == "error"), + "{refused}" + ); + let warning = refused["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|item| item["code"] == "messaging.project.wildcard-spelled-item") + .expect("the project warning remains visible beside the errors"); + assert_eq!( + warning["acceptedReason"], + "This isolated pilot intentionally grants every messaging scope." + ); +} + /// The arguments of every database command, pointed at a runtime /// configuration that does not exist, so each one fails before any database. fn database_commands(missing: &Path) -> Vec<(&'static str, Vec)> { diff --git a/crates/registry-messagingctl/src/lib.rs b/crates/registry-messagingctl/src/lib.rs index 82f4f625bf..e84996a4b2 100644 --- a/crates/registry-messagingctl/src/lib.rs +++ b/crates/registry-messagingctl/src/lib.rs @@ -999,9 +999,12 @@ fn check(args: &CheckArgs) -> Outcome { let Some(path) = &args.runtime_config else { return match given { Some((root, Ok(loaded))) => { - let warnings = loaded.warnings(); - if args.deny_warnings && warnings.warning_count() > 0 { - return Outcome::diagnosed(DOMAIN_REFUSAL_EXIT, warnings); + let mut warnings = loaded.warnings().clone(); + if args.project.is_some() { + registry_platform_config::apply_check_acceptances(root, &mut warnings); + } + if warnings.has_errors() || (args.deny_warnings && warnings.warning_count() > 0) { + return Outcome::diagnosed(DOMAIN_REFUSAL_EXIT, &warnings); } match package_report(&loaded) { Ok(mut report) => { @@ -1013,7 +1016,18 @@ fn check(args: &CheckArgs) -> Outcome { Err(refused) => refused, } } - Some((root, Err(error))) => package_refusal(root, &error), + Some((root, Err(error))) => { + let mut report = package_load_report(root, &error); + if args.project.is_some() { + registry_platform_config::apply_check_acceptances(root, &mut report); + } + let exit = if error.is_read_failure() { + OPERATIONAL_FAILURE_EXIT + } else { + DOMAIN_REFUSAL_EXIT + }; + Outcome::diagnosed(exit, &report) + } None => Outcome::refused( USAGE_EXIT, "usage.invalid", @@ -1024,7 +1038,14 @@ fn check(args: &CheckArgs) -> Outcome { }; let (given, mut refused) = match given { Some((root, Ok(loaded))) => (Some((root, loaded)), None), - Some((root, Err(error))) => (None, Some(package_refusal(root, &error))), + Some((root, Err(error))) => { + let exit = if error.is_read_failure() { + OPERATIONAL_FAILURE_EXIT + } else { + DOMAIN_REFUSAL_EXIT + }; + (None, Some((exit, package_load_report(root, &error)))) + } None => (None, None), }; let absolute = match std::path::absolute(path) { @@ -1045,27 +1066,23 @@ fn check(args: &CheckArgs) -> Outcome { (None, None) => CheckedAgainst::NamedPackage, }; let runtime = check_runtime_file(&absolute, against, args.environment); - let report = runtime_report(path, &absolute, &runtime); - let denied = report.has_errors() || (args.deny_warnings && report.warning_count() > 0); - if let Some(mut refused) = refused.take() { + let mut report = runtime_report(path, &absolute, &runtime); + if let Some((refused_exit, mut combined)) = refused.take() { + combined.extend(report); + if let Some(root) = &args.project { + registry_platform_config::apply_check_acceptances(root, &mut combined); + } let exit = if runtime.unavailable { OPERATIONAL_FAILURE_EXIT } else { - refused.exit + refused_exit }; - if let (Some(diagnostics), Value::Array(more)) = ( - refused.report["diagnostics"].as_array_mut(), - Outcome::diagnosed(exit, &report).report["diagnostics"].take(), - ) { - diagnostics.extend(more); - } - let package_files = refused.report["filesChecked"].as_u64().unwrap_or(0); - refused.report["filesChecked"] = - json!(package_files + report.files_checked().map_or(0, |files| files as u64)); - refused.exit = exit; - refused.view = View::Diagnostics; - return refused; + return Outcome::diagnosed(exit, &combined); + } + if let Some(root) = &args.project { + registry_platform_config::apply_check_acceptances(root, &mut report); } + let denied = report.has_errors() || (args.deny_warnings && report.warning_count() > 0); if denied { let exit = if runtime.unavailable { OPERATIONAL_FAILURE_EXIT diff --git a/crates/registry-platform-config/README.md b/crates/registry-platform-config/README.md index 80f8134db2..e847d1b5cb 100644 --- a/crates/registry-platform-config/README.md +++ b/crates/registry-platform-config/README.md @@ -131,6 +131,17 @@ accepted reference through the explicitly enabled environment or file provider. through `url_hook_destinations`. `registry-platform-hooks` activates the block under a fixed outbound policy: https, or http on a loopback host. +## Project check acceptances + +Adopter tooling calls `apply_check_acceptances(project_root, &mut report)` +once after collecting project diagnostics. It adds portable scopes and reads +the optional `check-acceptances.yaml` under the shared authored-file policy. +The file must be a readable regular file; a missing file means no acceptances. +Each entry accepts one exact warning code and scope, with a required reason. +Invalid files add errors to the report. Errors remain blocking; accepted +warnings retain their severity and explanation in the report and do not +contribute to `--deny-warnings`. There is no expiry or automatic reapproval. + ## Package `package` is the one package format every runtime serves from diff --git a/crates/registry-platform-config/src/check_acceptances.rs b/crates/registry-platform-config/src/check_acceptances.rs new file mode 100644 index 0000000000..560b94da1e --- /dev/null +++ b/crates/registry-platform-config/src/check_acceptances.rs @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +//! File handling for the shared project check acceptances. + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use registry_platform_yaml::{ + check_scope_path, read_check_acceptances, CheckAcceptances, CheckScope, Decoded, Diagnostic, + Expect, Reader, Report, Severity, Source, CHECK_ACCEPTANCES_FILE, CHECK_ACCEPTANCES_FORMAT, + MAXIMUM_DOCUMENT_BYTES, +}; +use serde_json::Value; + +/// Read project acceptances under the same expression policy as other +/// authored files. No environment value or secret is resolved. +pub fn read_project_check_acceptances( + file: &str, + bytes: &[u8], +) -> Result, Report> { + let mut hook = crate::AuthoredExpressions; + Reader::new(file) + .with_hook(&mut hook) + .read(bytes, &Expect::one(&CHECK_ACCEPTANCES_FORMAT))?; + read_check_acceptances(file, bytes) +} + +fn read_bounded(path: &Path) -> io::Result> { + let mut bytes = Vec::new(); + File::open(path)? + .take(MAXIMUM_DOCUMENT_BYTES as u64 + 1) + .read_to_end(&mut bytes)?; + Ok(bytes) +} + +fn unreadable(path: &Path) -> Diagnostic { + let mut diagnostic = Diagnostic::error( + "config.check-acceptances-unreadable", + "", + "the project check acceptances could not be read", + "Make check-acceptances.yaml a readable file, or remove it to use no acceptances.", + ); + diagnostic.source = Some(Source { + file: path.display().to_string(), + line: None, + column: None, + }); + diagnostic +} + +fn source_path(root: &Path, file: &str, cwd: Option<&Path>) -> Option { + let path = Path::new(file); + let within_project = |candidate: PathBuf| { + candidate + .canonicalize() + .ok() + .filter(|candidate| candidate.starts_with(root) && candidate != root) + }; + if path.is_absolute() { + return within_project(path.to_path_buf()); + } + // A caller may report either the path it opened relative to the process + // directory (`project/registry.yaml`) or a path inside its project + // (`registry.yaml`). Prefer the former only when it resolves inside this + // project, so a same-named process file cannot capture a bare label. + cwd.and_then(|cwd| within_project(cwd.join(path))) + .or_else(|| within_project(root.join(path))) +} + +/// Add portable scopes and apply the optional check-acceptances.yaml once +/// after a product has collected its project check diagnostics. This changes +/// advisory reporting only; no runtime authorization or validation is bypassed. +pub fn apply_check_acceptances(project_root: &Path, report: &mut Report) { + let acceptance_path = project_root.join(CHECK_ACCEPTANCES_FILE); + let bytes = std::fs::symlink_metadata(&acceptance_path).and_then(|metadata| { + if metadata.is_file() { + read_bounded(&acceptance_path) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a regular project file is required", + )) + } + }); + let accepted = match bytes { + Ok(bytes) => { + match read_project_check_acceptances(&acceptance_path.display().to_string(), &bytes) { + Ok(decoded) => { + report.extend(decoded.document.report_with(Vec::new())); + Some(decoded.value) + } + Err(errors) => { + report.extend(errors); + None + } + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(_) => { + report.push(unreadable(&acceptance_path)); + None + } + }; + + // Error findings stay blocking while warnings retain their own disposition. + let root = match project_root.canonicalize() { + Ok(root) => root, + Err(_) if report.has_errors() => return, + Err(_) => { + report.push(unreadable(project_root)); + return; + } + }; + let mut documents: BTreeMap = BTreeMap::new(); + let mut failures = Vec::new(); + let cwd = std::env::current_dir().ok(); + for diagnostic in report.diagnostics_mut() { + if diagnostic.severity != Severity::Warning { + continue; + } + let Some(source) = &diagnostic.source else { + continue; + }; + let Some(path) = source_path(&root, &source.file, cwd.as_deref()) else { + continue; + }; + let Ok(relative) = path.strip_prefix(&root) else { + continue; + }; + let file = relative.to_string_lossy().replace('\\', "/"); + let mut scope_path = diagnostic.path.clone(); + if matches!( + path.extension().and_then(|value| value.to_str()), + Some("yaml" | "yml" | "json") + ) && !diagnostic.path.is_empty() + { + if !documents.contains_key(&path) { + match read_bounded(&path) { + Ok(bytes) => match Reader::new(&source.file).scan(&bytes) { + Ok(Some(node)) => { + documents.insert(path.clone(), node.to_json_value()); + } + Ok(None) => {} + Err(errors) => failures.extend(errors.into_diagnostics()), + }, + Err(_) => failures.push(unreadable(&path)), + } + } + if let Some(document) = documents.get(&path) { + scope_path = check_scope_path(document, &diagnostic.path); + } + } + diagnostic.scope = Some(CheckScope { + file, + path: scope_path, + }); + } + for diagnostic in failures { + report.push(diagnostic); + } + if let Some(accepted) = accepted { + accepted.apply(report); + } +} + +#[cfg(test)] +mod tests { + use super::source_path; + + #[test] + fn a_cwd_relative_source_wins_over_a_nested_project_relative_collision() { + let base = tempfile::tempdir().unwrap(); + let root = base.path().join("project"); + let actual = root.join("registry.yaml"); + let decoy = root.join("project/registry.yaml"); + std::fs::create_dir_all(decoy.parent().unwrap()).unwrap(); + std::fs::write(&actual, "actual").unwrap(); + std::fs::write(&decoy, "decoy").unwrap(); + let root = root.canonicalize().unwrap(); + + assert_eq!( + source_path(&root, "project/registry.yaml", Some(base.path())), + Some(actual.canonicalize().unwrap()) + ); + } +} diff --git a/crates/registry-platform-config/src/lib.rs b/crates/registry-platform-config/src/lib.rs index 5b06c2bf2c..144d7804bf 100644 --- a/crates/registry-platform-config/src/lib.rs +++ b/crates/registry-platform-config/src/lib.rs @@ -9,6 +9,7 @@ //! validates the rest of its configuration contract. pub mod blocks; +mod check_acceptances; mod loader; mod offline; pub mod package; @@ -35,6 +36,7 @@ pub use blocks::{ MAX_OIDC_AUDIENCE_CHARACTERS, MIN_ATTEMPT_TIMEOUT_MILLISECONDS, MIN_HOOK_ATTEMPT_TIMEOUT_MILLISECONDS, }; +pub use check_acceptances::{apply_check_acceptances, read_project_check_acceptances}; pub use loader::{ contains_environment_expression, reject_environment_expressions_in_authored_yaml, AuthoredExpressions, LoadedRuntimeConfig, RemovedKey, RuntimeConfigError, RuntimeConfigLoader, diff --git a/crates/registry-platform-config/tests/check_acceptance_boundaries.rs b/crates/registry-platform-config/tests/check_acceptance_boundaries.rs new file mode 100644 index 0000000000..617f2f7ac9 --- /dev/null +++ b/crates/registry-platform-config/tests/check_acceptance_boundaries.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_platform_config::apply_check_acceptances; +use registry_platform_yaml::{Diagnostic, Report, Source}; + +const ACCEPTANCES: &str = r#"apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 +kind: PlatformCheckAcceptances +acceptances: + - code: example.access.unrestricted + scope: + file: registry.yaml + path: /accessProfiles/@id=registrar/rowBoundaries + reason: National registrars intentionally work across every district. +"#; + +fn warning(root: &std::path::Path) -> Diagnostic { + let mut diagnostic = Diagnostic::warning( + "example.access.unrestricted", + "/accessProfiles/0/rowBoundaries", + "This permission reaches every row.", + "Review this permission.", + ); + diagnostic.source = Some(Source { + file: root.join("registry.yaml").display().to_string(), + line: Some(3), + column: Some(5), + }); + diagnostic +} + +fn project() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join("registry.yaml"), + "accessProfiles:\n - id: registrar\n rowBoundaries: unrestricted\n", + ) + .unwrap(); + root +} + +#[test] +fn an_unrelated_error_does_not_hide_an_accepted_warning() { + let root = project(); + std::fs::write(root.path().join("check-acceptances.yaml"), ACCEPTANCES).unwrap(); + let mut report = Report::new(vec![ + warning(root.path()), + Diagnostic::error( + "example.project.invalid", + "/project", + "The project is invalid.", + "Correct the project.", + ), + ]); + + apply_check_acceptances(root.path(), &mut report); + + assert_eq!(report.error_count(), 1); + assert_eq!(report.warning_count(), 0); + assert_eq!(report.accepted_warning_count(), 1); + let warning = &report.diagnostics()[0]; + assert_eq!( + warning.scope.as_ref().map(|scope| scope.file.as_str()), + Some("registry.yaml") + ); + assert_eq!( + warning.accepted_reason.as_deref(), + Some("National registrars intentionally work across every district.") + ); +} + +#[cfg(unix)] +#[test] +fn an_acceptance_symlink_cannot_import_decisions_from_outside_the_project() { + let root = project(); + let outside = tempfile::tempdir().unwrap(); + let outside_file = outside.path().join("check-acceptances.yaml"); + std::fs::write(&outside_file, ACCEPTANCES).unwrap(); + std::os::unix::fs::symlink(&outside_file, root.path().join("check-acceptances.yaml")).unwrap(); + let mut report = Report::new(vec![warning(root.path())]); + + apply_check_acceptances(root.path(), &mut report); + + assert_eq!(report.accepted_warning_count(), 0); + assert_eq!(report.warning_count(), 1); + assert!(report.has_errors()); + assert!(report + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "config.check-acceptances-unreadable")); +} + +#[test] +fn a_bare_relative_source_ignores_a_process_file_outside_the_project() { + assert!( + std::path::Path::new("Cargo.toml").is_file(), + "this regression requires Cargo's workspace working directory" + ); + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join("Cargo.toml"), + "[package]\nname = 'fixture'\n", + ) + .unwrap(); + let mut diagnostic = Diagnostic::warning( + "example.project.warning", + "/package/name", + "The package name needs review.", + "Review the package name.", + ); + diagnostic.source = Some(Source { + file: "Cargo.toml".to_owned(), + line: Some(2), + column: Some(8), + }); + let mut report = Report::new(vec![diagnostic]); + + apply_check_acceptances(root.path(), &mut report); + + let scope = report.diagnostics()[0] + .scope + .as_ref() + .expect("the project-relative source has a scope"); + assert_eq!(scope.file, "Cargo.toml"); + assert_eq!(scope.path, "/package/name"); +} + +#[test] +fn a_project_root_warning_does_not_publish_an_empty_file_scope() { + let root = tempfile::tempdir().unwrap(); + let canonical = root.path().canonicalize().unwrap(); + let mut diagnostic = Diagnostic::warning( + "example.project.later-checks-skipped", + "", + "Later project checks did not run.", + "Correct the errors and check again.", + ); + diagnostic.source = Some(Source { + file: canonical.display().to_string(), + line: None, + column: None, + }); + let mut report = Report::new(vec![diagnostic]); + + apply_check_acceptances(&canonical, &mut report); + + assert!(report.diagnostics()[0].scope.is_none()); +} diff --git a/crates/registry-platform-config/tests/check_acceptances.rs b/crates/registry-platform-config/tests/check_acceptances.rs new file mode 100644 index 0000000000..2458a53a7f --- /dev/null +++ b/crates/registry-platform-config/tests/check_acceptances.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_platform_config::apply_check_acceptances; +use registry_platform_yaml::{Diagnostic, Report, Source}; + +fn warning(root: &std::path::Path) -> Diagnostic { + let mut diagnostic = Diagnostic::warning( + "example.access.unrestricted", + "/accessProfiles/0/rowBoundaries", + "This permission reaches every row.", + "Review this permission.", + ); + diagnostic.source = Some(Source { + file: root.join("registry.yaml").display().to_string(), + line: Some(3), + column: Some(5), + }); + diagnostic +} + +#[test] +fn project_file_is_optional_and_scopes_are_available_for_authoring_it() { + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join("registry.yaml"), + "accessProfiles:\n - id: registrar\n rowBoundaries: unrestricted\n", + ) + .unwrap(); + let mut report = Report::new(vec![warning(root.path())]); + apply_check_acceptances(root.path(), &mut report); + assert_eq!(report.warning_count(), 1); + let scope = report.diagnostics()[0].scope.as_ref().unwrap(); + assert_eq!(scope.file, "registry.yaml"); + assert_eq!(scope.path, "/accessProfiles/@id=registrar/rowBoundaries"); +} + +#[test] +fn project_acceptance_is_applied_and_a_missing_reason_is_an_error() { + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join("registry.yaml"), + "accessProfiles:\n - id: registrar\n rowBoundaries: unrestricted\n", + ) + .unwrap(); + let input = "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances:\n - code: example.access.unrestricted\n scope:\n file: registry.yaml\n path: /accessProfiles/@id=registrar/rowBoundaries\n reason: National registrars work across all districts.\n"; + std::fs::write(root.path().join("check-acceptances.yaml"), input).unwrap(); + let mut report = Report::new(vec![warning(root.path())]); + apply_check_acceptances(root.path(), &mut report); + assert_eq!(report.warning_count(), 0); + assert_eq!(report.accepted_warning_count(), 1); + assert!(!report.has_errors()); + std::fs::write( + root.path().join("check-acceptances.yaml"), + input.replace( + " reason: National registrars work across all districts.\n", + "", + ), + ) + .unwrap(); + let mut report = Report::new(vec![warning(root.path())]); + apply_check_acceptances(root.path(), &mut report); + assert!(report.has_errors()); + assert_eq!(report.warning_count(), 1); +} + +#[test] +fn an_unreadable_acceptance_file_is_a_visible_failure() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("check-acceptances.yaml")).unwrap(); + let mut report = Report::default(); + apply_check_acceptances(root.path(), &mut report); + assert!(report.has_errors()); + assert!(report + .diagnostics() + .iter() + .any(|d| d.code == "config.check-acceptances-unreadable")); +} + +#[test] +fn acceptance_reasons_follow_the_shared_authored_expression_policy() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("check-acceptances.yaml"), + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances:\n - code: example.access.unrestricted\n scope: {file: registry.yaml, path: ''}\n reason: '${CANARY_ACCEPTANCE_REASON}'\n").unwrap(); + let mut report = Report::default(); + apply_check_acceptances(root.path(), &mut report); + assert!(report.has_errors()); + assert!(report + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "config.substitution-not-allowed")); + assert!(!report.render_human().contains("CANARY_ACCEPTANCE_REASON")); +} diff --git a/crates/registry-platform-yaml/Cargo.toml b/crates/registry-platform-yaml/Cargo.toml index 0ef005e411..9e100c4fcc 100644 --- a/crates/registry-platform-yaml/Cargo.toml +++ b/crates/registry-platform-yaml/Cargo.toml @@ -30,3 +30,7 @@ url.workspace = true jsonschema.workspace = true regex.workspace = true schemars.workspace = true + +[[example]] +name = "check-acceptances-schema" +required-features = ["schema"] diff --git a/crates/registry-platform-yaml/README.md b/crates/registry-platform-yaml/README.md index b7dab5d1d5..9986d2c4d2 100644 --- a/crates/registry-platform-yaml/README.md +++ b/crates/registry-platform-yaml/README.md @@ -17,7 +17,7 @@ that may reach a terminal, a CI log, or an agent's context: the input; - anchors, aliases, merge keys, tags, several documents, and non-string keys are refused, so a document is a plain tree and an alias cannot expand it; -- a diagnostic never repeats a scalar value from the file or the +- a diagnostic's message never repeats a rejected scalar value from the file or the environment (CFG-SEC-3). Messages name keys, paths, accepted values, bounds, and the expected envelope. A message a type's own `Deserialize` code writes is never passed through, and keys and file names are escaped @@ -177,6 +177,8 @@ Reader codes have two segments; product codes have three. | `config.out-of-range` | a number is outside its bounds | | `config.substitution` | a `${...}` expression cannot be filled: it is not well formed, its variable is unset or empty, `${NAME:?message}` refused, or the value holds a NUL byte (reported by a substitution hook) | | `config.substitution-not-allowed` | a `${...}` expression is written where substitution is not allowed, or an envelope member was substituted | +| `config.check-acceptance-invalid` | an acceptance needs a valid check code, project-relative scope, and nonblank reason | +| `config.check-acceptance-duplicate` | the same check code and scope have more than one acceptance | | `config.too-many-problems` | the file has more problems than the reader shows; this last diagnostic counts the rest | A test fails when a code in the source is missing from this table or the @@ -472,6 +474,21 @@ What the reader adds on top of the parser, and why: built from input**, rerun the suite comparison, and re-check the depth and empty-value behavior above. +## Project check acceptances + +`read_check_acceptances` reads the shared `PlatformCheckAcceptances` format. +Each entry matches a warning's exact `code` and `scope` (`file`, `path`) and +provides a nonblank reason. `CheckAcceptances::apply` keeps the diagnostic, +its severity, and its reason, while `Report::warning_count` counts only active +warnings. `Report::accepted_warning_count` counts the accepted warnings. +Errors are never accepted. Unmatched entries are harmless. + +`check_scope_path` uses existing unique `id`, `action`, and `recordType` +values to address resource list entries; it records no behavior fingerprint. +The caller supplies the project-relative file. File discovery belongs to +`registry-platform-config::apply_check_acceptances`, so this crate stays free +of filesystem and environment I/O. + ## Tests Test names start with the rule they prove (`cfg_yaml_2_...`, diff --git a/crates/registry-platform-yaml/examples/check-acceptances-schema.rs b/crates/registry-platform-yaml/examples/check-acceptances-schema.rs new file mode 100644 index 0000000000..12225f0408 --- /dev/null +++ b/crates/registry-platform-yaml/examples/check-acceptances-schema.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 + +fn main() -> Result<(), Box> { + let mut arguments = std::env::args_os().skip(1); + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--output")) { + return Err("usage: check-acceptances-schema --output ".into()); + } + let output = arguments.next().ok_or("--output requires a directory")?; + if arguments.next().is_some() { + return Err("unexpected argument".into()); + } + let directory = std::path::Path::new(&output); + std::fs::create_dir_all(directory)?; + let schema = registry_platform_yaml::check_acceptances_schema(); + let mut text = serde_json::to_string_pretty(&schema)?; + text.push('\n'); + std::fs::write(directory.join("check-acceptances.schema.json"), text)?; + Ok(()) +} diff --git a/crates/registry-platform-yaml/src/check_acceptances.rs b/crates/registry-platform-yaml/src/check_acceptances.rs new file mode 100644 index 0000000000..1458382719 --- /dev/null +++ b/crates/registry-platform-yaml/src/check_acceptances.rs @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Project-owned acceptance of specific advisory warnings. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + escape_pointer_segment, ApiVersion, Decoded, DisplayText, EnvelopeRule, Expect, FormatSpec, + LocalId, Reader, Report, Severity, +}; + +pub const CHECK_ACCEPTANCES_FILE: &str = "check-acceptances.yaml"; +pub const CHECK_ACCEPTANCES_API_VERSION: &str = + "id.registrystack.org/formats/platform/check-acceptances/v1alpha1"; +pub const CHECK_ACCEPTANCES_KIND: &str = "PlatformCheckAcceptances"; +pub const CHECK_ACCEPTANCES_SCHEMA_ID: &str = "https://id.registrystack.org/schemas/platform/check-acceptances/check-acceptances.v1alpha1.schema.json"; +pub const CHECK_ACCEPTANCES_FORMAT: FormatSpec<'static> = FormatSpec { + kind: CHECK_ACCEPTANCES_KIND, + envelope: EnvelopeRule::ApiVersionKind { + api_versions: &[ApiVersion::current(CHECK_ACCEPTANCES_API_VERSION)], + retired_api_versions: &[], + }, + removed_keys: &[], +}; + +/// A source file relative to the project and the resource path a check reports. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CheckScope { + #[cfg_attr(feature = "schema", schemars(length(min = 1)))] + pub file: String, + /// A pointer whose list positions use existing unique identifiers when available. + pub path: String, +} + +/// One warning accepted for one exact scope, with the project's explanation. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CheckAcceptance { + #[cfg_attr( + feature = "schema", + schemars(regex(pattern = "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$")) + )] + pub code: String, + pub scope: CheckScope, + pub reason: DisplayText, +} + +/// The optional project file shared by Registry Stack checking commands. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CheckAcceptances { + pub acceptances: Vec, +} + +/// The authored document's schema, including its shared reader envelope. +#[cfg(feature = "schema")] +pub fn check_acceptances_schema() -> Value { + let mut schema = serde_json::to_value(schemars::schema_for!(CheckAcceptances)) + .expect("the acceptance schema is JSON"); + schema["$id"] = Value::String(CHECK_ACCEPTANCES_SCHEMA_ID.to_owned()); + schema["properties"]["apiVersion"] = serde_json::json!({ + "type": "string", "const": CHECK_ACCEPTANCES_API_VERSION + }); + schema["properties"]["kind"] = serde_json::json!({ + "type": "string", "const": CHECK_ACCEPTANCES_KIND + }); + schema["required"] = serde_json::json!(["apiVersion", "kind", "acceptances"]); + schema["$defs"]["CheckAcceptance"]["properties"]["reason"] = serde_json::json!({ + "allOf": [{"$ref":"#/$defs/DisplayText"}, {"type":"string", "pattern":"\\S"}] + }); + schema["$defs"]["CheckScope"]["properties"]["file"] = serde_json::json!({ + "type":"string", "minLength":1, + "pattern":r"^[^/\\:\x00-\x1f\x7f-\x9f]+(/[^/\\:\x00-\x1f\x7f-\x9f]+)*$", + "not":{"pattern":r"(^|/)\.{1,2}(/|$)"} + }); + schema["$defs"]["CheckScope"]["properties"]["path"] = serde_json::json!({ + "type":"string", "pattern":r"^(?:/(?:[^~\x00-\x1f\x7f-\x9f]|~[01])*)?$" + }); + schema +} + +impl CheckAcceptances { + /// Keep every finding and its severity. Only warnings may be accepted. + /// Unmatched entries are harmless, including after a finding is fixed. + pub fn apply(&self, report: &mut Report) { + for diagnostic in report.diagnostics_mut() { + if diagnostic.severity != Severity::Warning { + continue; + } + if let Some(acceptance) = self.acceptances.iter().find(|acceptance| { + acceptance.code == diagnostic.code + && diagnostic.scope.as_ref() == Some(&acceptance.scope) + }) { + diagnostic.accepted_reason = Some(acceptance.reason.as_str().to_owned()); + } + } + } +} + +/// Read the shared closed format and validate its exact-match keys. +pub fn read_check_acceptances( + file: &str, + bytes: &[u8], +) -> Result, Report> { + let decoded = Reader::new(file) + .decode::(bytes, &Expect::one(&CHECK_ACCEPTANCES_FORMAT))?; + let mut diagnostics = Vec::new(); + let mut seen = BTreeSet::new(); + for (index, acceptance) in decoded.value.acceptances.iter().enumerate() { + let base = format!("/acceptances/{index}"); + let code_valid = acceptance.code.contains('.') + && acceptance.code.split('.').all(|part| { + part.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && part.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' + }) + }); + let file_valid = !acceptance.scope.file.contains('\\') + && !acceptance.scope.file.contains(':') + && !acceptance.scope.file.chars().any(char::is_control) + && acceptance + .scope + .file + .split('/') + .all(|part| !part.is_empty() && part != "." && part != ".."); + let path_valid = (acceptance.scope.path.is_empty() + || acceptance.scope.path.starts_with('/')) + && !acceptance.scope.path.chars().any(char::is_control) + && acceptance.scope.path.match_indices('~').all(|(index, _)| { + matches!( + acceptance.scope.path.as_bytes().get(index + 1), + Some(b'0' | b'1') + ) + }); + for (valid, member, message, action) in [ + ( + code_valid, + "code", + "the check code must be dotted lowercase segments", + "Copy the warning's code from its check report.", + ), + ( + file_valid, + "scope/file", + "the scope file must be a path inside the project", + "Copy the warning's project-relative scope.file from its check report.", + ), + ( + path_valid, + "scope/path", + "the scope path must be empty or start with '/' and use valid pointer escapes", + "Copy the warning's scope.path from its check report.", + ), + ( + !acceptance.reason.as_str().trim().is_empty(), + "reason", + "an accepted warning requires a nonblank reason", + "Explain why the project accepts this warning.", + ), + ] { + if !valid { + diagnostics.push(decoded.document.diagnostic_at_value( + Severity::Error, + "config.check-acceptance-invalid", + &format!("{base}/{member}"), + message, + action, + )); + } + } + if !seen.insert((&acceptance.code, &acceptance.scope)) { + diagnostics.push(decoded.document.diagnostic_at_value( + Severity::Error, + "config.check-acceptance-duplicate", + &base, + "a check code and scope may have only one acceptance", + "Keep one entry for this warning and scope, with its reason.", + )); + } + } + if diagnostics.is_empty() { + Ok(decoded) + } else { + Err(decoded.document.report_with(diagnostics)) + } +} + +/// Address resource lists by their existing identifiers, without inspecting +/// behavior or recording a configuration fingerprint. Other positions stay numeric. +pub fn check_scope_path(document: &Value, pointer: &str) -> String { + let Some(tail) = pointer.strip_prefix('/') else { + return pointer.to_owned(); + }; + let mut node = Some(document); + let mut scope = String::new(); + for encoded in tail.split('/') { + let segment = encoded.replace("~1", "/").replace("~0", "~"); + let mut selector = None; + node = match node { + Some(Value::Array(items)) => { + let item = segment + .parse::() + .ok() + .and_then(|index| items.get(index)); + if let Some(item) = item { + for key in ["id", "action", "recordType"] { + if let Some(id) = item.get(key).and_then(Value::as_str) { + if LocalId::new(id).is_ok() + && items + .iter() + .filter(|item| { + item.get(key).and_then(Value::as_str) == Some(id) + }) + .count() + == 1 + { + selector = Some(format!("@{key}={id}")); + break; + } + } + } + } + item + } + Some(Value::Object(object)) => object.get(&segment), + _ => None, + }; + scope.push('/'); + scope.push_str(&escape_pointer_segment( + selector.as_deref().unwrap_or(&segment), + )); + } + scope +} diff --git a/crates/registry-platform-yaml/src/diagnostic.rs b/crates/registry-platform-yaml/src/diagnostic.rs index 5b6e4f50f8..a11539e26a 100644 --- a/crates/registry-platform-yaml/src/diagnostic.rs +++ b/crates/registry-platform-yaml/src/diagnostic.rs @@ -6,6 +6,8 @@ use std::fmt; use serde::{Deserialize, Serialize}; +use crate::CheckScope; + /// Whether a diagnostic refuses the input. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -55,9 +57,10 @@ pub struct Related { /// One problem, in the shape every checking command reports (CFG-DIAG-1). /// -/// A diagnostic names keys, the path, accepted values, bounds, and the -/// expected envelope. It never repeats a scalar value read from a file or the -/// environment (CFG-SEC-3). +/// Messages name keys, paths, accepted values, bounds, and the expected +/// envelope without repeating rejected scalar values (CFG-SEC-3). Warning +/// acceptance metadata separately identifies the resource and carries the +/// project's deliberately authored reason. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] @@ -76,6 +79,12 @@ pub struct Diagnostic { pub source: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub related: Vec, + /// Project-relative identity copied into a check acceptance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + /// The project's reason for accepting this advisory warning. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accepted_reason: Option, } impl Diagnostic { @@ -95,6 +104,8 @@ impl Diagnostic { suggested_action: suggested_action.into(), source: None, related: Vec::new(), + scope: None, + accepted_reason: None, } } @@ -146,6 +157,11 @@ impl Diagnostic { out.push_str(&clean(&related.message)); out.push('\n'); } + if let Some(reason) = &self.accepted_reason { + out.push_str(" accepted: "); + out.push_str(&clean(reason)); + out.push('\n'); + } out } @@ -208,6 +224,10 @@ impl Report { &self.diagnostics } + pub fn diagnostics_mut(&mut self) -> &mut [Diagnostic] { + &mut self.diagnostics + } + pub fn into_diagnostics(self) -> Vec { self.diagnostics } @@ -225,7 +245,21 @@ impl Report { } pub fn warning_count(&self) -> usize { - self.count(Severity::Warning) + self.diagnostics + .iter() + .filter(|diagnostic| { + diagnostic.severity == Severity::Warning && diagnostic.accepted_reason.is_none() + }) + .count() + } + + pub fn accepted_warning_count(&self) -> usize { + self.diagnostics + .iter() + .filter(|diagnostic| { + diagnostic.severity == Severity::Warning && diagnostic.accepted_reason.is_some() + }) + .count() } fn count(&self, severity: Severity) -> usize { @@ -266,9 +300,19 @@ impl Report { /// any related notes, and a closing summary line. pub fn render_human(&self) -> String { let mut out = String::new(); - for diagnostic in &self.diagnostics { + for diagnostic in self.diagnostics.iter().filter(|diagnostic| { + diagnostic.severity != Severity::Warning || diagnostic.accepted_reason.is_none() + }) { out.push_str(&diagnostic.render_human()); } + if self.accepted_warning_count() > 0 { + out.push_str("Accepted warnings:\n"); + for diagnostic in self.diagnostics.iter().filter(|diagnostic| { + diagnostic.severity == Severity::Warning && diagnostic.accepted_reason.is_some() + }) { + out.push_str(&diagnostic.render_human()); + } + } out.push_str(&self.summary()); out.push('\n'); out @@ -295,6 +339,14 @@ impl Report { warnings, plural(warnings, "warning", "warnings") ); + let accepted = self.accepted_warning_count(); + if accepted > 0 { + summary.push_str(&format!( + ", {} accepted {}", + accepted, + plural(accepted, "warning", "warnings") + )); + } if files > 0 { summary.push_str(&format!(" in {} {}", files, plural(files, "file", "files"))); } diff --git a/crates/registry-platform-yaml/src/lib.rs b/crates/registry-platform-yaml/src/lib.rs index 329ed81a27..ba02b86ce3 100644 --- a/crates/registry-platform-yaml/src/lib.rs +++ b/crates/registry-platform-yaml/src/lib.rs @@ -105,6 +105,7 @@ //! `#[serde(tag = "...")]` or `#[serde(untagged)]`: they buffer the mapping //! without positions. +mod check_acceptances; mod de; mod diagnostic; mod document; @@ -118,6 +119,13 @@ mod structure; mod types; mod union; +#[cfg(feature = "schema")] +pub use check_acceptances::check_acceptances_schema; +pub use check_acceptances::{ + check_scope_path, read_check_acceptances, CheckAcceptance, CheckAcceptances, CheckScope, + CHECK_ACCEPTANCES_API_VERSION, CHECK_ACCEPTANCES_FILE, CHECK_ACCEPTANCES_FORMAT, + CHECK_ACCEPTANCES_KIND, CHECK_ACCEPTANCES_SCHEMA_ID, +}; pub use de::{Invalid, SHARED_BLOCK_PREFIX}; pub use diagnostic::{Diagnostic, Related, Report, Severity, Source}; pub use document::{ diff --git a/crates/registry-platform-yaml/src/messages.rs b/crates/registry-platform-yaml/src/messages.rs index bee7b574a1..7d06f97c95 100644 --- a/crates/registry-platform-yaml/src/messages.rs +++ b/crates/registry-platform-yaml/src/messages.rs @@ -21,6 +21,8 @@ pub struct CodeInfo { /// reader's have two (`yaml.` for the YAML subset, `config.` for envelope, /// key, and type problems). pub const CODES: &[CodeInfo] = &[ + CodeInfo { code: "config.check-acceptance-invalid", meaning: "an acceptance needs a valid check code, project-relative scope and nonblank reason" }, + CodeInfo { code: "config.check-acceptance-duplicate", meaning: "the same check code and scope have more than one acceptance" }, CodeInfo { code: "yaml.too-large", meaning: "the document is larger than 1 MiB" }, CodeInfo { code: "yaml.not-utf8", meaning: "the document is not UTF-8" }, CodeInfo { code: "yaml.syntax", meaning: "the YAML is not well formed and no narrower code applies" }, @@ -121,6 +123,8 @@ impl Problem { pub fn into_diagnostic(self, file: &str, artifact: Option<&str>) -> Diagnostic { Diagnostic { severity: self.severity, + scope: None, + accepted_reason: None, code: self.code, artifact: artifact.map(str::to_string), path: self.pointer, diff --git a/crates/registry-platform-yaml/tests/check_acceptances.rs b/crates/registry-platform-yaml/tests/check_acceptances.rs new file mode 100644 index 0000000000..3f20e8f539 --- /dev/null +++ b/crates/registry-platform-yaml/tests/check_acceptances.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_platform_yaml::{ + check_scope_path, read_check_acceptances, CheckScope, Diagnostic, Report, Severity, +}; +use serde_json::json; + +const ACCEPTANCES: &str = r#"apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 +kind: PlatformCheckAcceptances +acceptances: + - code: breg.access.target-unrestricted-rows + scope: + file: registry.yaml + path: /accessProfiles/@id=registrar/rowBoundaries + reason: Registrars intentionally work across all districts. +"#; + +fn warning() -> Diagnostic { + let mut diagnostic = Diagnostic::warning( + "breg.access.target-unrestricted-rows", + "/accessProfiles/0/rowBoundaries", + "The permission reaches every row of its target type.", + "Review this permission.", + ); + diagnostic.scope = Some(CheckScope { + file: "registry.yaml".into(), + path: "/accessProfiles/@id=registrar/rowBoundaries".into(), + }); + diagnostic +} + +#[test] +fn acceptance_keeps_the_finding_and_reason_but_removes_it_from_the_warning_gate() { + let accepted = read_check_acceptances("check-acceptances.yaml", ACCEPTANCES.as_bytes()) + .unwrap() + .value; + let mut report = Report::new(vec![warning()]); + accepted.apply(&mut report); + assert_eq!(report.warning_count(), 0); + assert_eq!(report.accepted_warning_count(), 1); + assert!(!report.has_errors()); + let diagnostics = report.to_json_value(); + assert_eq!(diagnostics[0]["severity"], "warning"); + assert_eq!(diagnostics[0]["path"], "/accessProfiles/0/rowBoundaries"); + assert_eq!( + diagnostics[0]["acceptedReason"], + "Registrars intentionally work across all districts." + ); + assert!(report.render_human().contains("Accepted warnings:")); + assert!(report.render_human().contains("Registrars intentionally")); + assert!(report.summary().contains("1 accepted warning")); +} + +#[test] +fn acceptance_matches_the_exact_code_file_and_scope_and_never_an_error() { + let accepted = read_check_acceptances("check-acceptances.yaml", ACCEPTANCES.as_bytes()) + .unwrap() + .value; + let original = warning(); + let mut different_code = original.clone(); + different_code.code = "breg.access.profile-unrestricted-rows".into(); + let mut different_file = original.clone(); + different_file.scope.as_mut().unwrap().file = "modules/core/module.yaml".into(); + let mut different_subject = original.clone(); + different_subject.scope.as_mut().unwrap().path = + "/accessProfiles/@id=clerk/rowBoundaries".into(); + let mut error = original.clone(); + error.severity = Severity::Error; + let mut report = Report::new(vec![ + original, + different_code, + different_file, + different_subject, + error, + ]); + accepted.apply(&mut report); + assert_eq!(report.accepted_warning_count(), 1); + assert_eq!(report.warning_count(), 3); + assert_eq!(report.error_count(), 1); + assert!(report.diagnostics()[4].accepted_reason.is_none()); +} + +#[test] +fn invalid_acceptances_are_refused_without_repeating_their_values() { + for (from, to) in [ + ( + "Registrars intentionally work across all districts.", + "' '", + ), + ("file: registry.yaml", "file: ../canary-private.yaml"), + ( + "breg.access.target-unrestricted-rows", + "canary-invalid-code", + ), + ("/@id=registrar/rowBoundaries", "/@id=registrar/~2bad"), + ("reason:", "canaryUnknownMember:"), + ] { + let input = ACCEPTANCES.replace(from, to); + let report = + read_check_acceptances("check-acceptances.yaml", input.as_bytes()).unwrap_err(); + assert!(report.has_errors(), "{input}"); + assert!(!report.render_human().contains("canary-private")); + assert!(!report.render_human().contains("canary-invalid-code")); + } +} + +#[test] +fn duplicate_acceptances_are_refused_and_unused_entries_are_harmless() { + let input = format!( + "{ACCEPTANCES}{}", + ACCEPTANCES.split_once("acceptances:\n").unwrap().1 + ); + assert!(read_check_acceptances("check-acceptances.yaml", input.as_bytes()).is_err()); + let accepted = read_check_acceptances("check-acceptances.yaml", ACCEPTANCES.as_bytes()) + .unwrap() + .value; + let mut report = Report::default(); + accepted.apply(&mut report); + assert!(report.is_empty()); +} + +#[test] +fn scope_uses_existing_resource_identifiers_and_survives_reordering() { + let first = json!({"accessProfiles":[ + {"id":"registrar","permissions":{"actions":[{"action":"correct","targets":[{"recordType":"household","rowBoundaries":"unrestricted"}]}]}}, + {"id":"clerk","rowBoundaries":"unrestricted"} + ]}); + let second = json!({"accessProfiles":[first["accessProfiles"][1].clone(), first["accessProfiles"][0].clone()]}); + let scope = "/accessProfiles/@id=registrar/permissions/actions/@action=correct/targets/@recordType=household/rowBoundaries"; + assert_eq!( + check_scope_path( + &first, + "/accessProfiles/0/permissions/actions/0/targets/0/rowBoundaries" + ), + scope + ); + assert_eq!( + check_scope_path( + &second, + "/accessProfiles/1/permissions/actions/0/targets/0/rowBoundaries" + ), + scope + ); +} + +#[test] +fn scope_does_not_merge_duplicate_ids_or_expose_arbitrary_values() { + let duplicate = json!({"items":[{"id":"same"},{"id":"same"}]}); + assert_eq!(check_scope_path(&duplicate, "/items/1/id"), "/items/1/id"); + let arbitrary = json!({"items":[{"id":"CANARY SECRET / value"}]}); + assert_eq!(check_scope_path(&arbitrary, "/items/0/id"), "/items/0/id"); +} + +#[cfg(feature = "schema")] +#[test] +fn acceptance_schema_matches_its_generated_file_and_refuses_a_blank_reason() { + let schema = registry_platform_yaml::check_acceptances_schema(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/platform/schemas/check-acceptances.schema.json"); + let committed: serde_json::Value = + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(schema, committed); + let validator = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .compile(&schema) + .unwrap(); + let document = registry_platform_yaml::Reader::new("check-acceptances.yaml") + .scan(ACCEPTANCES.as_bytes()) + .unwrap() + .unwrap() + .to_json_value(); + assert!(validator.is_valid(&document)); + let mut blank = document; + blank["acceptances"][0]["reason"] = json!(" "); + assert!(!validator.is_valid(&blank)); + for file in [ + "../outside.yaml", + "/absolute.yaml", + "dir/../outside.yaml", + "dir\\outside.yaml", + "", + ] { + let mut invalid = blank.clone(); + invalid["acceptances"][0]["reason"] = json!("An intentional warning."); + invalid["acceptances"][0]["scope"]["file"] = json!(file); + assert!(!validator.is_valid(&invalid), "{file}"); + } +} diff --git a/crates/registry-render/src/check.rs b/crates/registry-render/src/check.rs index c1032c0f54..ccd6d462b9 100644 --- a/crates/registry-render/src/check.rs +++ b/crates/registry-render/src/check.rs @@ -156,6 +156,10 @@ pub fn run(request: &CheckRequest<'_>, stdout: &mut dyn Write, stderr: &mut dyn } let mut report = Report::new(diagnostics); report.set_files_checked(files); + if let Some(dir) = bundle_dir { + registry_platform_config::apply_check_acceptances(dir, &mut report); + files = report.files_checked().unwrap_or(files); + } let exit = if unavailable { OPERATIONAL_FAILURE_EXIT } else if report.has_errors() || (request.deny_warnings && report.warning_count() > 0) { @@ -428,7 +432,10 @@ fn load_refusal(dir: &Path, problem: RenderProblem) -> Vec { /// label table were read when the bundle loaded. Returns the findings and /// the number of files read. fn scan_unread_yaml(dir: &Path, bundle: &Bundle) -> (Vec, usize) { - let mut read = BTreeSet::from([MANIFEST_FILE.to_owned()]); + let mut read = BTreeSet::from([ + MANIFEST_FILE.to_owned(), + registry_platform_yaml::CHECK_ACCEPTANCES_FILE.to_owned(), + ]); read.extend( bundle .label_sources diff --git a/crates/registry-render/tests/check_acceptances.rs b/crates/registry-render/tests/check_acceptances.rs new file mode 100644 index 0000000000..d0666fcc95 --- /dev/null +++ b/crates/registry-render/tests/check_acceptances.rs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::process::Command; + +#[test] +fn project_check_acceptance_keeps_the_warning_and_allows_the_warning_gate() { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().canonicalize().unwrap().join("bundle"); + let init = Command::new(env!("CARGO_BIN_EXE_registry-render")) + .arg("init") + .arg(&project) + .output() + .unwrap(); + assert!( + init.status.success(), + "{}", + String::from_utf8_lossy(&init.stderr) + ); + std::fs::write(project.join("notes.yaml"), "title: Project notes\n").unwrap(); + let check = || { + Command::new(env!("CARGO_BIN_EXE_registry-render")) + .args(["check", "--bundle"]) + .arg(&project) + .args(["--format", "json", "--deny-warnings"]) + .output() + .unwrap() + }; + let before = check(); + assert_eq!(before.status.code(), Some(1)); + let before: serde_json::Value = serde_json::from_slice(&before.stdout).unwrap(); + assert_eq!(before["errors"], 0, "{before}"); + let files_before_acceptance = before["filesChecked"].as_u64().unwrap(); + let warning = before["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|diagnostic| diagnostic["code"] == "render.bundle.unread-file") + .unwrap(); + let input = serde_json::json!({ + "apiVersion": registry_platform_yaml::CHECK_ACCEPTANCES_API_VERSION, + "kind": registry_platform_yaml::CHECK_ACCEPTANCES_KIND, + "acceptances": [{"code":warning["code"], "scope":warning["scope"], "reason":"Project notes are intentionally kept beside the bundle."}] + }); + std::fs::write( + project.join("check-acceptances.yaml"), + serde_json::to_vec(&input).unwrap(), + ) + .unwrap(); + let after = check(); + assert!( + after.status.success(), + "{}", + String::from_utf8_lossy(&after.stdout) + ); + let after: serde_json::Value = serde_json::from_slice(&after.stdout).unwrap(); + assert_eq!( + after["filesChecked"], + serde_json::json!(files_before_acceptance + 1) + ); + assert_eq!(after["warnings"], 0); + assert_eq!(after["diagnostics"].as_array().unwrap().len(), 1); + assert_eq!(after["diagnostics"][0]["severity"], "warning"); + assert_eq!( + after["diagnostics"][0]["acceptedReason"], + "Project notes are intentionally kept beside the bundle." + ); +} diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index 5d2a392b88..a794016488 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -368,7 +368,11 @@ fn run(cli: Cli) -> Result { Command::Serve(_) => unreachable!("serve replaces the ctl process before report dispatch"), Command::Init(args) => project::init(&args.project, &args.template), Command::Check(args) => { - let checked = project::check(&args.project, args.deny_warnings); + let checked = if args.runtime_config.is_some() { + project::check(&args.project, false) + } else { + project::check_with_acceptances(&args.project, args.deny_warnings) + }; match &args.runtime_config { Some(runtime_config) => project::check_runtime_config( &args.project, diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index 5a0cc0cb13..783406763d 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -314,6 +314,45 @@ pub(super) fn check(project: &Path, deny_warnings: bool) -> Result { })) } +pub(super) fn check_with_acceptances(project: &Path, deny_warnings: bool) -> Result { + finish_check_with_acceptances(project, deny_warnings, check(project, false)) +} + +fn finish_check_with_acceptances( + project: &Path, + deny_warnings: bool, + checked: Result, +) -> Result { + let (mut report, mut checked) = match checked { + Ok(checked) => { + let mut report = Report::new(serde_json::from_value(checked["diagnostics"].clone())?); + if let Some(files) = checked["filesChecked"] + .as_u64() + .and_then(|files| usize::try_from(files).ok()) + { + report.set_files_checked(files); + } + (report, Some(checked)) + } + Err(error) => match crate::configuration_report(&error) { + Some(report) => (report.clone(), None), + None => return Err(error), + }, + }; + registry_platform_config::apply_check_acceptances(project, &mut report); + if refuses(&report, deny_warnings) { + return Err(report.into()); + } + let mut checked = checked + .take() + .expect("an error-free project has a check report"); + checked["diagnostics"] = report.to_json_value(); + if let Some(files) = report.files_checked() { + checked["filesChecked"] = json!(files); + } + Ok(checked) +} + pub(super) fn test(project: &Path) -> Result { let checked = checked_project(project, true, false)?; if checked.fixtures.is_empty() { @@ -652,28 +691,9 @@ pub(super) fn check_runtime_config( } diagnostic }; - let mut report = Report::new(runtime.diagnostics.into_iter().map(named).collect()); - report.set_files_checked(1); - if !refuses(&report, deny_warnings) { - let mut checked = checked?; - checked["runtimeConfig"] = json!(given); - if let (Some(diagnostics), Value::Array(warnings)) = ( - checked["diagnostics"].as_array_mut(), - report.to_json_value(), - ) { - // The project check reads its own runtime example, which may be - // the file given: a finding it already holds is not repeated. - let reported = diagnostics.clone(); - diagnostics.extend(warnings.into_iter().filter(|w| !reported.contains(w))); - } - if let Some(files) = checked["filesChecked"].as_u64() { - checked["filesChecked"] = json!(files + 1); - } - return Ok(checked); - } - let refused = match checked { - // The project passed: its warnings and the files it read stay in - // the refusal beside the runtime file's diagnostics. + let mut runtime_report = Report::new(runtime.diagnostics.into_iter().map(named).collect()); + runtime_report.set_files_checked(1); + let (mut combined, mut checked) = match checked { Ok(checked) => { let mut project = Report::new( serde_json::from_value(checked["diagnostics"].clone()) @@ -685,8 +705,7 @@ pub(super) fn check_runtime_config( { project.set_files_checked(files); } - project.extend(unreported(report, project.diagnostics())); - project + (project, Some(checked)) } Err(error) => match crate::configuration_report(&error) { Some(project) => { @@ -694,17 +713,30 @@ pub(super) fn check_runtime_config( if project.files_checked().is_none() { project.set_files_checked(1); } - project.extend(unreported(report, project.diagnostics())); - project + (project, None) } None => return Err(error), }, }; - Err(RuntimeConfigRefusal { - report: refused, - unavailable: runtime.unavailable, + let reported = combined.diagnostics().to_vec(); + combined.extend(unreported(runtime_report, &reported)); + registry_platform_config::apply_check_acceptances(project, &mut combined); + if refuses(&combined, deny_warnings) { + return Err(RuntimeConfigRefusal { + report: combined, + unavailable: runtime.unavailable, + } + .into()); + } + let mut checked = checked + .take() + .expect("an error-free project has a check report"); + checked["runtimeConfig"] = json!(given); + checked["diagnostics"] = combined.to_json_value(); + if let Some(files) = combined.files_checked() { + checked["filesChecked"] = json!(files); } - .into()) + Ok(checked) } /// `report` without the diagnostics the project check already reported, so a @@ -1146,7 +1178,8 @@ mod tests { " - id: registry-update\n label: Registry record update\n", " - id: registry-update\n label: Registry record update\n - id: registry-copy\n label: registry record update\n", ); - let report = check(&project, false).expect("warnings do not normally refuse check"); + let report = + check_with_acceptances(&project, false).expect("warnings do not normally refuse check"); let warning = report["diagnostics"] .as_array() .unwrap() @@ -1154,8 +1187,10 @@ mod tests { .find(|item| item["code"] == "scheduling.project.duplicate-service-label") .unwrap_or_else(|| panic!("the product check reports the duplicate label: {report}")); assert_eq!(warning["path"], "/services/1/label"); + let files_without_acceptances = report["filesChecked"].as_u64().unwrap(); - let error = check(&project, true).expect_err("--deny-warnings refuses the warning"); + let error = check_with_acceptances(&project, true) + .expect_err("--deny-warnings refuses the warning"); let report = crate::configuration_report(&error).expect("a structured warning report"); assert_eq!(report.error_count(), 0); assert_eq!(report.warning_count(), 1); @@ -1163,6 +1198,58 @@ mod tests { report.diagnostics()[0].code, "scheduling.project.duplicate-service-label" ); + + let scope = warning["scope"] + .as_object() + .expect("a stable warning scope"); + fs::write( + project.join("check-acceptances.yaml"), + format!( + "apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1\nkind: PlatformCheckAcceptances\nacceptances:\n - code: scheduling.project.duplicate-service-label\n scope:\n file: {}\n path: {}\n reason: These two pilot services intentionally use the same public label.\n", + scope["file"].as_str().unwrap(), + scope["path"].as_str().unwrap(), + ), + ) + .unwrap(); + let accepted = check_with_acceptances(&project, true) + .expect("an accepted warning does not make --deny-warnings fail"); + assert_eq!( + accepted["filesChecked"], + files_without_acceptances + 1, + "the present acceptance file is counted once" + ); + let warning = accepted["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|item| item["code"] == "scheduling.project.duplicate-service-label") + .unwrap(); + assert_eq!( + warning["acceptedReason"], + "These two pilot services intentionally use the same public label." + ); + + edit( + &project, + SCHEDULING_RECORDS_FILE, + "kind: SchedulingRecords\n", + "kind: SchedulingRecords\nstray: true\n", + ); + let error = check_with_acceptances(&project, true) + .expect_err("an unrelated records error still refuses the project"); + let report = crate::configuration_report(&error).unwrap(); + assert_eq!(report.error_count(), 1); + assert_eq!(report.warning_count(), 0); + assert_eq!(report.accepted_warning_count(), 1); + let accepted = report + .diagnostics() + .iter() + .find(|diagnostic| diagnostic.code == "scheduling.project.duplicate-service-label") + .unwrap(); + assert_eq!( + accepted.accepted_reason.as_deref(), + Some("These two pilot services intentionally use the same public label.") + ); } #[test] diff --git a/docs/site/src/content/docs/reference/configuration-files.mdx b/docs/site/src/content/docs/reference/configuration-files.mdx index 73d7d76a21..a0a2430ab7 100644 --- a/docs/site/src/content/docs/reference/configuration-files.mdx +++ b/docs/site/src/content/docs/reference/configuration-files.mdx @@ -419,6 +419,50 @@ error[config.invalid-value] /private/tmp/agent-R5-work/my-registry/registry.yaml crates/registry-breg/src/contract/sentinel.rs, Scopes and RequiredScopes; crates/registry-bregctl/src/lib.rs, canonicalize_project_path. */} +## Accept an intentional project warning + +If a project warning describes behavior you intend, record the decision in +`check-acceptances.yaml` at the project root. The project check reads this optional file +automatically. For Registry Render, put it at the bundle root. Registry Manifest reads it beside +the metadata manifest or at the root passed to `validate-profiles`. + +Run your project's check with `--format json`, then copy the warning's `code` and `scope` into +an entry with a reason. For example, a Base Registry Engine project might deliberately allow a +national registrar to correct facilities in every district: + +```yaml +apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 +kind: PlatformCheckAcceptances +acceptances: + - code: breg.access.target-unrestricted-rows + scope: + file: registry.yaml + path: /accessProfiles/@id=registrar/permissions/actions/@action=correct-facility/targets/@recordType=facility/rowBoundaries + reason: National registrars intentionally manage facilities across all districts. +``` + +Copy `scope.path` exactly from your check report. It uses existing resource identifiers where +available; it is separate from the diagnostic's numeric JSON pointer. `scope.file` is relative +to the project, so the decision also works in another checkout. Matching is exact, with no +wildcards. Use a reason suitable for version control and check reports. + +Run the project check again. The warning remains in its JSON report with `acceptedReason`, and +human output groups accepted warnings with their reasons. `--deny-warnings` refuses only warnings +that remain active. Errors always refuse the check, and a malformed acceptance file is itself an +error. You can also check this file on its own with `bregctl check --file check-acceptances.yaml` +or `evidencectl check --file check-acceptances.yaml`. + +Commit the file alongside the project. The acceptance remains until someone edits or removes +it, including when the configuration changes. There is no expiry or automatic reapproval, and +unused entries do not block checks. To restore a warning to the active list, remove its entry +and rerun the check. Acceptances change reporting, not runtime permissions or validation. + +{/* Evidence: crates/registry-platform-yaml/src/check_acceptances.rs, CheckAcceptances and + check_scope_path(); crates/registry-platform-config/src/check_acceptances.rs, + apply_check_acceptances(); crates/registry-platform-yaml/tests/check_acceptances.rs; + crates/registry-bregctl/tests/cli.rs; crates/registry-evidencectl/src/file_check.rs; + crates/registry-render/src/check.rs; crates/registry-manifest-cli/src/main.rs. */} + ## Check a file before you deploy it The Check command column under [formats by product](#formats-by-product) names the command that diff --git a/editors/configure.py b/editors/configure.py index c721032013..424dc7bccc 100644 --- a/editors/configure.py +++ b/editors/configure.py @@ -37,6 +37,7 @@ # language server; every other pattern is YAML. SCHEMAS = { "breg": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/breg/generated/authoring/registry-project.schema.json", "registry.yaml"), ("products/breg/generated/authoring/registry-module.schema.json", "modules/**/module.yaml"), ("products/breg/generated/runtime/runtime.schema.json", "runtime.yaml"), @@ -60,6 +61,7 @@ "breg-mcp": (("products/breg/generated/mcp-runtime/mcp-runtime.schema.json", "runtime.yaml"),), "breg-review": (("products/breg/generated/review-runtime/review-runtime.schema.json", "runtime.yaml"),), "coordinator": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/coordinator/generated/project/project.schema.json", "workflow.yaml"), ("products/coordinator/generated/runtime/runtime.schema.json", "runtime.yaml"), ("products/coordinator/generated/runtime/runtime.schema.json", "runtime.example.yaml"), @@ -67,6 +69,7 @@ ("products/coordinator/generated/scenarios/scenarios.schema.json", "scenarios.yaml"), ), "casework": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/casework/generated/project/project.schema.json", "casework.yaml"), ("products/casework/generated/runtime/runtime.schema.json", "runtime.yaml"), ("products/casework/generated/runtime/runtime.schema.json", "runtime.example.yaml"), @@ -83,6 +86,7 @@ ("products/casework/generated/dev-clients/dev-clients.schema.json", "dev-clients.yaml"), ), "scheduling": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/scheduling/generated/project/project.schema.json", "scheduling.yaml"), ("products/scheduling/generated/runtime/runtime.schema.json", "runtime.yaml"), ("products/scheduling/generated/runtime/runtime.schema.json", "runtime.example.yaml"), @@ -91,6 +95,7 @@ ("products/scheduling/generated/fixture/fixture.schema.json", "fixtures/*.yml"), ), "messaging": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/messaging/generated/authoring/project.schema.json", "messaging.yaml"), ("products/messaging/generated/authoring/template.schema.json", "templates/*/*/template.yaml"), ("products/messaging/generated/authoring/provider.schema.json", "providers/*/provider.yaml"), @@ -98,6 +103,7 @@ ("products/messaging/generated/runtime/runtime.schema.json", "runtime.example.yaml"), ), "documents": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/documents/generated/project/project.schema.json", "documents.yaml"), ("products/documents/generated/runtime/runtime.schema.json", "runtime.yaml"), ("products/documents/generated/runtime/runtime.schema.json", "runtime.example.yaml"), @@ -106,24 +112,29 @@ ("products/evidence/generated/oid4vci-runtime/oid4vci-runtime.schema.json", "{document}"), ), "discovery": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/discovery/schemas/origins.schema.json", "origins.yaml"), ("products/discovery/schemas/runtime.schema.json", "runtime.yaml"), ("products/discovery/schemas/evidence-mapping.schema.json", "mappings/*.yaml"), ("products/discovery/schemas/evidence-mapping.schema.json", "mappings/*.yml"), ), "render": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/render/schemas/bundle.schema.json", "manifest.yaml"), ("products/render/schemas/labels.schema.json", "labels/*.yaml"), ("products/render/schemas/runtime.schema.json", "runtime.yaml"), ), "manifest": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/manifest/schemas/metadata.schema.json", "{document}"), ("products/manifest/schemas/profile.schema.json", "**/profile.yaml"), ), "platform": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ("products/platform/schemas/task-connection.schema.json", "task-connection.yaml"), ), "evidence": ( + ("products/platform/schemas/check-acceptances.schema.json", "check-acceptances.yaml"), ( "crates/registry-evidencectl/schemas/authoring/project.schema.json", "evidence.yaml", diff --git a/editors/tests/test_configure.py b/editors/tests/test_configure.py index 0ca40831ff..ef4911904b 100644 --- a/editors/tests/test_configure.py +++ b/editors/tests/test_configure.py @@ -91,7 +91,7 @@ def test_breg_schema_snapshots_tasks_and_idempotent_merge(self): settings = json.loads(settings_path.read_text()) self.assertEqual(settings["editor.tabSize"], 4) schemas = settings["yaml.schemas"] - self.assertEqual(len(schemas), 7) + self.assertEqual(len(schemas), 8) dev_clients_schema = (project / ".registry-stack-editor/schemas/dev-clients.v1alpha1.schema.json").as_uri() self.assertEqual(schemas[dev_clients_schema], [str(project / "dev-clients.yaml")]) project_schema = (project / ".registry-stack-editor/schemas/registry-project.schema.json").as_uri() @@ -169,6 +169,27 @@ def test_every_shared_mapping_names_a_maintained_schema(self): with self.subTest(product=product, source=source): self.assertTrue((ROOT / source).is_file(), source) + def test_every_project_checker_maps_shared_check_acceptances(self): + shared = ( + "products/platform/schemas/check-acceptances.schema.json", + "check-acceptances.yaml", + ) + for product in ( + "breg", + "evidence", + "casework", + "scheduling", + "messaging", + "coordinator", + "documents", + "discovery", + "manifest", + "render", + "platform", + ): + with self.subTest(product=product): + self.assertIn(shared, configure.SCHEMAS[product]) + def test_casework_maps_only_current_authored_files(self): project = self.project( "casework", @@ -181,6 +202,9 @@ def test_casework_maps_only_current_authored_files(self): self.assertEqual( schemas, { + (managed / "check-acceptances.schema.json").as_uri(): [ + str(project / "check-acceptances.yaml") + ], (managed / "project.schema.json").as_uri(): [str(project / "casework.yaml")], (managed / "runtime.schema.json").as_uri(): [ str(project / "runtime.yaml"), @@ -275,6 +299,9 @@ def test_messaging_maps_each_authored_file_to_its_schema(self): self.assertEqual( schemas, { + (managed / "check-acceptances.schema.json").as_uri(): [ + str(project / "check-acceptances.yaml") + ], (managed / "project.schema.json").as_uri(): [str(project / "messaging.yaml")], (managed / "template.schema.json").as_uri(): [ str(project / "templates/*/*/template.yaml") @@ -331,7 +358,7 @@ def test_two_products_share_workspace_without_schema_or_task_collision(self): configure.configure("casework", casework, self.workspace, None) configure.configure("breg", breg, self.workspace, None) settings = json.loads(settings_path.read_text()) - self.assertEqual(len(settings["yaml.schemas"]), 13) + self.assertEqual(len(settings["yaml.schemas"]), 15) breg_dev_clients = (breg / ".registry-stack-editor/schemas/dev-clients.v1alpha1.schema.json").as_uri() self.assertEqual(settings["yaml.schemas"][breg_dev_clients], [str(breg / "dev-clients.yaml")]) self.assertEqual(settings["yaml.schemas"][unrelated_uri], [str(self.workspace / "unrelated/registry.yaml")]) @@ -347,6 +374,9 @@ def test_render_bundle_maps_its_manifest_label_tables_and_runtime(self): self.assertEqual( schemas, { + (managed / "check-acceptances.schema.json").as_uri(): [ + str(bundle / "check-acceptances.yaml") + ], (managed / "bundle.schema.json").as_uri(): [str(bundle / "manifest.yaml")], (managed / "labels.schema.json").as_uri(): [str(bundle / "labels/*.yaml")], (managed / "runtime.schema.json").as_uri(): [str(bundle / "runtime.yaml")], @@ -369,6 +399,9 @@ def test_manifest_maps_its_document_and_profile_descriptors(self): self.assertEqual( schemas, { + (managed / "check-acceptances.schema.json").as_uri(): [ + str(project / "check-acceptances.yaml") + ], (managed / "metadata.schema.json").as_uri(): [str(project / "catalog.metadata.yaml")], (managed / "profile.schema.json").as_uri(): [str(project / "**/profile.yaml")], }, @@ -387,7 +420,14 @@ def test_platform_maps_its_task_connection_file(self): managed = project / ".registry-stack-editor/schemas" self.assertEqual( schemas, - {(managed / "task-connection.schema.json").as_uri(): [str(project / "task-connection.yaml")]}, + { + (managed / "check-acceptances.schema.json").as_uri(): [ + str(project / "check-acceptances.yaml") + ], + (managed / "task-connection.schema.json").as_uri(): [ + str(project / "task-connection.yaml") + ], + }, ) self.assertEqual( (managed / "task-connection.schema.json").read_bytes(), @@ -497,6 +537,9 @@ def test_evidence_maps_its_json_files_through_the_json_language_server(self): self.assertEqual( yaml_schemas, { + (schemas / "check-acceptances.schema.json").as_uri(): [ + str(evidence / "check-acceptances.yaml") + ], (schemas / "project.schema.json").as_uri(): [str(evidence / "evidence.yaml")], (schemas / "target.schema.json").as_uri(): [ str(evidence / "*-target.yaml"), str(evidence / "target.yaml"), diff --git a/products/breg/CHANGELOG.md b/products/breg/CHANGELOG.md index 3fc35a15c7..9927c1d435 100644 --- a/products/breg/CHANGELOG.md +++ b/products/breg/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- `bregctl check` and project-backed `explain` read an optional + `check-acceptances.yaml`. An exact, reasoned acceptance keeps its warning + visible while allowing `--deny-warnings` to pass; errors remain refusals. - Name one `ALTER .. OWNER TO ` statement for each registry object a split-role runtime role owns when apply refuses that ownership, in place of `REASSIGN OWNED BY`, which also moved what the runtime role owns outside diff --git a/products/breg/examples/formats/ctl-report.json b/products/breg/examples/formats/ctl-report.json index 9a64b9868e..7b70d230c3 100644 --- a/products/breg/examples/formats/ctl-report.json +++ b/products/breg/examples/formats/ctl-report.json @@ -1654,6 +1654,10 @@ "message": "profile `facility-reader` can read `operator` records linked to `facility` through `facility-assignment` on read path `operators`; it sees fields jurisdiction, operator-code, status under its own `facility` row boundaries, and access profiles declared for `operator` do not apply", "path": "/accessProfiles/1/permissions/records/1/readPaths/0", "rank": "widens-access", + "scope": { + "file": "registry.yaml", + "path": "/accessProfiles/@id=facility-reader/permissions/records/@recordType=facility/readPaths/0" + }, "severity": "warning", "source": { "column": 15, diff --git a/products/casework/CHANGELOG.md b/products/casework/CHANGELOG.md index 637fd043d9..7000caccde 100644 --- a/products/casework/CHANGELOG.md +++ b/products/casework/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- `caseworkctl check` reads an optional project `check-acceptances.yaml`, keeps + exactly matched warnings visible with their reasons, and excludes only those + accepted warnings from `--deny-warnings`. - Split-mode refusals for a runtime role that owns Casework objects name an exact `ALTER ... OWNER TO` statement for each Casework schema object, run as a database administrator, then `caseworkctl apply`, in place of diff --git a/products/casework/contracts/cli/report.schema.json b/products/casework/contracts/cli/report.schema.json index 337e967a32..6fe9b351e8 100644 --- a/products/casework/contracts/cli/report.schema.json +++ b/products/casework/contracts/cli/report.schema.json @@ -421,6 +421,25 @@ ], "type": "object" }, + "CheckScope": { + "additionalProperties": false, + "description": "A source file relative to the project and the resource path a check reports.", + "properties": { + "file": { + "minLength": 1, + "type": "string" + }, + "path": { + "description": "A pointer whose list positions use existing unique identifiers when available.", + "type": "string" + } + }, + "required": [ + "file", + "path" + ], + "type": "object" + }, "ClockPolicy": { "description": "The serialized form of [`ClockPolicy`]: the shape the serializer emits.", "oneOf": [ @@ -801,8 +820,15 @@ }, "Diagnostic": { "additionalProperties": false, - "description": "One problem, in the shape every checking command reports (CFG-DIAG-1).\n\nA diagnostic names keys, the path, accepted values, bounds, and the\nexpected envelope. It never repeats a scalar value read from a file or the\nenvironment (CFG-SEC-3).", + "description": "One problem, in the shape every checking command reports (CFG-DIAG-1).\n\nMessages name keys, paths, accepted values, bounds, and the expected\nenvelope without repeating rejected scalar values (CFG-SEC-3). Warning\nacceptance metadata separately identifies the resource and carries the\nproject's deliberately authored reason.", "properties": { + "acceptedReason": { + "description": "The project's reason for accepting this advisory warning.", + "type": [ + "string", + "null" + ] + }, "artifact": { "description": "The document's `kind`, or the expected kind when the document's own is\nabsent or wrong and the reader expected exactly one.", "type": [ @@ -826,6 +852,17 @@ }, "type": "array" }, + "scope": { + "anyOf": [ + { + "$ref": "#/$defs/CheckScope" + }, + { + "type": "null" + } + ], + "description": "Project-relative identity copied into a check acceptance." + }, "severity": { "$ref": "#/$defs/Severity" }, diff --git a/products/coordinator/CHANGELOG.md b/products/coordinator/CHANGELOG.md index 48c22fbf66..ced4e875e5 100644 --- a/products/coordinator/CHANGELOG.md +++ b/products/coordinator/CHANGELOG.md @@ -4,6 +4,9 @@ First release of Registry Coordinator. +- `coordinatorctl check --project` reads an optional + `check-acceptances.yaml`, keeps exactly matched warnings visible with their + reasons, and excludes only those accepted warnings from `--deny-warnings`. - Coordinator executes finite, acyclic Workflows across durable waits, outages, and restarts. Projects separate generated contracts, reusable types, named pure Functions, local exact Operation aliases, and one Workflow graph. Functions take one typed object of named inputs. diff --git a/products/discovery/CHANGELOG.md b/products/discovery/CHANGELOG.md index a88749c1ed..aad6346be0 100644 --- a/products/discovery/CHANGELOG.md +++ b/products/discovery/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- `discoveryctl check --project` reads an optional `check-acceptances.yaml`. + An exact, reasoned acceptance keeps its warning visible while allowing + `--deny-warnings` to pass; errors remain refusals. + ## 0.43.0 - 2026-10-21 - BREAKING: the current provider-publication parser rejects prohibited display diff --git a/products/documents/CHANGELOG.md b/products/documents/CHANGELOG.md index 6bc6479e12..1ae1b5cf38 100644 --- a/products/documents/CHANGELOG.md +++ b/products/documents/CHANGELOG.md @@ -11,3 +11,7 @@ metadata and status only: it cannot grant, read the bytes, or list the grants (`GET /v1/documents/{documentId}/grants` is the custodian's alone and answers the reserving system `profile.not-authorized`), so the issuer never learns whom the citizen shared with. + +`documentsctl check --project` reads an optional `check-acceptances.yaml`, +keeps exactly matched warnings visible with their reasons, and excludes only +those accepted warnings from `--deny-warnings`. diff --git a/products/evidence/CHANGELOG.md b/products/evidence/CHANGELOG.md index 78f59b5c62..9b16ddb0d0 100644 --- a/products/evidence/CHANGELOG.md +++ b/products/evidence/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- `evidencectl check` reads an optional `check-acceptances.yaml`. An exact, + reasoned acceptance keeps its advisory warning visible while allowing + `--deny-warnings` to pass; errors remain refusals. - BREAKING: the OID4VCI wallet delivery runtime requires an `audit` block. It records each offer's creating client and subject as keyed pseudonyms, and each wallet delivery outcome, in a durable journal. To upgrade, store a diff --git a/products/identifiers/contracts/catalog-source.json b/products/identifiers/contracts/catalog-source.json index dbd3eafb40..7e056c66cd 100644 --- a/products/identifiers/contracts/catalog-source.json +++ b/products/identifiers/contracts/catalog-source.json @@ -515,13 +515,21 @@ "description": "Registry Manifest metadata manifest and profile descriptor JSON Schema." }, { - "glob": "products/platform/schemas/*.schema.json", + "glob": "products/platform/schemas/task-connection.schema.json", "sourcePath": "crates/registry-thunderid-tooling/src/schema.rs", "owner": "platform", "status": "active", "compatibilityLine": "v1alpha1", "description": "Registry Stack task connection JSON Schema." }, + { + "glob": "products/platform/schemas/check-acceptances.schema.json", + "sourcePath": "crates/registry-platform-yaml/src/check_acceptances.rs", + "owner": "platform", + "status": "active", + "compatibilityLine": "v1alpha1", + "description": "Registry Stack project check acceptances JSON Schema." + }, { "glob": "products/evidence/generated/oid4vci-runtime/*.schema.json", "sourcePath": "crates/registry-evidence-oid4vci/src/schema.rs", diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index d24316b15c..a5de96be62 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -4598,7 +4598,7 @@ }, "artifact": { "path": "products/casework/contracts/cli/report.schema.json", - "sha256": "d01b81b1c12f785be1f4d9ed7089cfd625722c1ce4cd80ba1ed1d393331aac02", + "sha256": "158d1cb317dcc9bbee833684aefff09f215c066ee2f03d61f980088f5c52fc10", "mediaType": "application/schema+json" } }, @@ -5232,6 +5232,24 @@ "mediaType": "application/schema+json" } }, + { + "uri": "https://id.registrystack.org/schemas/platform/check-acceptances/check-acceptances.v1alpha1.schema.json", + "kind": "schema", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "platform", + "title": "CheckAcceptances", + "description": "Registry Stack project check acceptances JSON Schema.", + "source": { + "path": "crates/registry-platform-yaml/src/check_acceptances.rs", + "sha256": "9f01dd394d730e590710e430baf1928ee3e02bb52c1d3ab53b41e447ac692fd2" + }, + "artifact": { + "path": "products/platform/schemas/check-acceptances.schema.json", + "sha256": "3fa0953ce1c3effc0a35c61e5c93d484d5b948e5f64478999c6ea671e588b3d1", + "mediaType": "application/schema+json" + } + }, { "uri": "https://id.registrystack.org/schemas/platform/task-connection/task-connection.v1alpha1.schema.json", "kind": "schema", diff --git a/products/manifest/CHANGELOG.md b/products/manifest/CHANGELOG.md index 50e8793335..85f6e82776 100644 --- a/products/manifest/CHANGELOG.md +++ b/products/manifest/CHANGELOG.md @@ -7,6 +7,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- `registry-manifest validate` and `validate-profiles` read an optional + `check-acceptances.yaml`. An exact, reasoned acceptance keeps its warning + visible while allowing `--deny-warnings` to pass; errors remain refusals. + - BREAKING: `render` selects its artifact with `--artifact-format`; report `--format` does not apply to artifact stdout. `publish` writes its bundle to `--output` and can emit a separate human or JSON report. Replace `render diff --git a/products/messaging/CHANGELOG.md b/products/messaging/CHANGELOG.md index 88ac70ebe8..05599e9d88 100644 --- a/products/messaging/CHANGELOG.md +++ b/products/messaging/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- `messagingctl check --project` reads an optional `check-acceptances.yaml`, + keeps exactly matched warnings visible with their reasons, and excludes only + those accepted warnings from `--deny-warnings`. - A split-mode apply that refuses a runtime role owning Messaging objects lists an exact `ALTER ... OWNER TO` statement for each Messaging schema object in place of `REASSIGN OWNED BY`, which also moves the role's objects diff --git a/products/platform/CONFIG-CONVENTIONS.md b/products/platform/CONFIG-CONVENTIONS.md index 6f4a80728a..ee9391ba55 100644 --- a/products/platform/CONFIG-CONVENTIONS.md +++ b/products/platform/CONFIG-CONVENTIONS.md @@ -720,6 +720,16 @@ each entry with `file`, `line`, `column`, `path`, and `message`. The type lives in `registry-platform-yaml` and the ctl report envelope (`ok`, `command`, `status`, ..., `diagnostics`) carries it unchanged. +For a project warning, optional `scope` contains a project-relative `file` +and the resource `path` used for exact acceptance matching. List positions +use an existing unique `id`, `action`, or `recordType` where available, +written as `@id=value`, `@action=value`, or `@recordType=value`; other +positions stay numeric. The diagnostic's original `path` and source position +are unchanged. An accepted warning also carries `acceptedReason`, the +project's deliberately authored explanation. These fields identify and +explain the project decision; diagnostic messages and refusal remedies still +follow the value-free rule in CFG-SEC-3. + **CFG-DIAG-2 (MUST). Human output puts the position first:** ```text @@ -744,7 +754,9 @@ key, and type problems. A code is never reused for a different condition. **CFG-DIAG-4 (MUST). Exit codes:** 0 when no error was reported (warnings allowed), 1 when the input was refused, 2 for a usage error, 3 when something the command depends on was unavailable. Every checking command takes -`--deny-warnings`, which makes a reported warning exit 1. +`--deny-warnings`, which makes an unaccepted warning exit 1. An accepted +warning retains `severity: warning` and its `acceptedReason`, and does not +contribute to the active warning count. Errors always refuse the input. **CFG-DIAG-5 (MUST). A check reports every problem it can find in one run.** Structural problems are those the reader finds before decoding: every `yaml.` @@ -779,6 +791,18 @@ variant, or invalid value holds a `#` with no space before it, YAML reads the comment only after a space: put a space before the `#` that starts the comment." The sentence names no part of the value (CFG-SEC-3). +**CFG-DIAG-7 (MUST). Project warning acceptances use one shared file.** +`check-acceptances.yaml` is optional and uses the `PlatformCheckAcceptances` +format. Each entry has `code`, `scope` (`file` and `path`), and a nonblank +`reason`. Matching is exact; no wildcard, fingerprint, expiry, or inferred +approval is involved. Only warning diagnostics can be accepted. The decision +remains until an author edits or removes it, and unused entries are harmless. +Malformed or unreadable acceptance files refuse the project check. The file +is a regular file in the project, not a symbolic link. A check retains accepted +warnings in JSON and groups them with their reasons in human output, including +when another diagnostic refuses the project. The shared parser and matcher +tests, consuming check tests, and generated schema enforce this rule. + ## 11. Schemas, editors, and the registry **CFG-SCHEMA-1 (MUST). Every format is registered** in @@ -983,6 +1007,7 @@ Each MUST rule appears once; SHOULD rules are in the last row. |---|---| | CFG-ENV-1, 4; CFG-YAML-2 to 8; CFG-VAL-1 to 3; CFG-EMPTY-1; CFG-SEC-2, 3; CFG-DIAG-1, 2, 5; CFG-CHANGE-2 | reader unit tests named with the rule ID; the conformance corpus; for CFG-ENV-1 also the convention lint (`const` envelope) | | CFG-VAL-11 | shared reader and comparison-helper unit tests; generated product schemas; product check tests for structured warnings and `--deny-warnings` | +| CFG-DIAG-7 | shared acceptance parser, matcher, schema drift, and file-boundary tests; consuming product check tests for exact matching, refusal preservation, and `--deny-warnings` | | CFG-YAML-1 | `disallowed-methods` in every `clippy.toml` in the repository, with `check-yaml-reader-boundary.sh`; the conformance corpus | | CFG-ENV-2, 3, 6; CFG-NAME-1 to 5; CFG-ID-1, 6, 7; CFG-QTY-1 to 4; CFG-VAL-6, 7; CFG-EMPTY-2, 4; CFG-SEC-1; CFG-EMBED-2; CFG-SCHEMA-1, 3 to 6 | `check-config-conventions.py` over the registry and every schema, with the exceptions register as ratchet; for CFG-EMPTY-2 the lint checks the sentinel shape and review classifies each member as granting or restricting; for CFG-SEC-1 also the `SecretReference` parser tests; for CFG-ID-6 also the source lint (set types) and the corpus's duplicated set item; for CFG-QTY-4 also the corpus boundary sweep over the bounds each format's registered schema states | | CFG-SCHEMA-8; CFG-CHANGE-1 | source lint over the reader-type closure (`flatten`, `untagged`, `tag`, `alias`, set types); the corpus unknown-key sweep | diff --git a/products/platform/config-formats.yaml b/products/platform/config-formats.yaml index 76e61ea9c4..b5e08f60d5 100644 --- a/products/platform/config-formats.yaml +++ b/products/platform/config-formats.yaml @@ -63,6 +63,43 @@ sharedSchemas: formats: + - id: platform/check-acceptances + product: platform + title: Project check acceptances + files: [check-acceptances.yaml] + syntax: yaml + audience: authored + stability: unpromised + current: + apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 + kind: PlatformCheckAcceptances + checkedBy: + file: crates/registry-platform-yaml/src/check_acceptances.rs + symbol: CHECK_ACCEPTANCES_API_VERSION + target: + apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 + kind: PlatformCheckAcceptances + schema: + path: products/platform/schemas/check-acceptances.schema.json + id: https://id.registrystack.org/schemas/platform/check-acceptances/check-acceptances.v1alpha1.schema.json + origin: generated + generator: cargo run -p registry-platform-yaml --features schema --example check-acceptances-schema -- --output products/platform/schemas + driftCheck: crates/registry-platform-yaml/tests/check_acceptances.rs + reader: + crate: registry-platform-yaml + file: crates/registry-platform-yaml/src/check_acceptances.rs + function: read_check_acceptances + type: CheckAcceptances + check: bregctl check --file {file} + example: products/platform/examples/check-acceptances.yaml + conformance: + requiredText: /acceptances/0/reason + optionalText: none + integer: none + boolean: none + securityMembers: [] + restrictingMembers: [] + # breg - id: breg/project product: breg diff --git a/products/platform/examples/check-acceptances.yaml b/products/platform/examples/check-acceptances.yaml new file mode 100644 index 0000000000..5bc4891520 --- /dev/null +++ b/products/platform/examples/check-acceptances.yaml @@ -0,0 +1,8 @@ +apiVersion: id.registrystack.org/formats/platform/check-acceptances/v1alpha1 +kind: PlatformCheckAcceptances +acceptances: + - code: breg.access.target-unrestricted-rows + scope: + file: registry.yaml + path: /accessProfiles/@id=registrar/permissions/actions/@action=correct-facility/targets/@recordType=facility/rowBoundaries + reason: National registrars intentionally manage facilities across all districts. diff --git a/products/platform/schemas/check-acceptances.schema.json b/products/platform/schemas/check-acceptances.schema.json new file mode 100644 index 0000000000..36551e792e --- /dev/null +++ b/products/platform/schemas/check-acceptances.schema.json @@ -0,0 +1,91 @@ +{ + "$defs": { + "CheckAcceptance": { + "additionalProperties": false, + "description": "One warning accepted for one exact scope, with the project's explanation.", + "properties": { + "code": { + "pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$", + "type": "string" + }, + "reason": { + "allOf": [ + { + "$ref": "#/$defs/DisplayText" + }, + { + "pattern": "\\S", + "type": "string" + } + ] + }, + "scope": { + "$ref": "#/$defs/CheckScope" + } + }, + "required": [ + "code", + "scope", + "reason" + ], + "type": "object" + }, + "CheckScope": { + "additionalProperties": false, + "description": "A source file relative to the project and the resource path a check reports.", + "properties": { + "file": { + "minLength": 1, + "not": { + "pattern": "(^|/)\\.{1,2}(/|$)" + }, + "pattern": "^[^/\\\\:\\x00-\\x1f\\x7f-\\x9f]+(/[^/\\\\:\\x00-\\x1f\\x7f-\\x9f]+)*$", + "type": "string" + }, + "path": { + "pattern": "^(?:/(?:[^~\\x00-\\x1f\\x7f-\\x9f]|~[01])*)?$", + "type": "string" + } + }, + "required": [ + "file", + "path" + ], + "type": "object" + }, + "DisplayText": { + "description": "Human-readable display text. Bidirectional embedding, override, and isolate controls, byte-order marks, line and paragraph separators, and Unicode noncharacters are refused; multilingual joiners and direction marks remain valid.", + "not": { + "pattern": "[

‪-‮⁦-⁩﷐-﷯￾￿]|🿾|🿿|𯿾|𯿿|𿿾|𿿿|񏿾|񏿿|񟿾|񟿿|񯿾|񯿿|񿿾|񿿿|򏿾|򏿿|򟿾|򟿿|򯿾|򯿿|򿿾|򿿿|󏿾|󏿿|󟿾|󟿿|󯿾|󯿿|󿿾|󿿿|􏿾|􏿿" + }, + "type": "string" + } + }, + "$id": "https://id.registrystack.org/schemas/platform/check-acceptances/check-acceptances.v1alpha1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "The optional project file shared by Registry Stack checking commands.", + "properties": { + "acceptances": { + "items": { + "$ref": "#/$defs/CheckAcceptance" + }, + "type": "array" + }, + "apiVersion": { + "const": "id.registrystack.org/formats/platform/check-acceptances/v1alpha1", + "type": "string" + }, + "kind": { + "const": "PlatformCheckAcceptances", + "type": "string" + } + }, + "required": [ + "apiVersion", + "kind", + "acceptances" + ], + "title": "CheckAcceptances", + "type": "object" +} diff --git a/products/platform/scripts/check-config-conventions.py b/products/platform/scripts/check-config-conventions.py index b8917460a1..dd4b334e48 100644 --- a/products/platform/scripts/check-config-conventions.py +++ b/products/platform/scripts/check-config-conventions.py @@ -283,6 +283,20 @@ def capitalize(text: str) -> str: return text[:1].upper() + text[1:] +def reads_through_platform_yaml(reader_crate: str, text: str) -> bool: + """Whether reader source visibly uses the shared YAML reader boundary.""" + + code = conformance.code_only(text) + return ( + "registry_platform_yaml" in code + or "RuntimeConfigLoader" in code + or ( + reader_crate == "registry-platform-yaml" + and re.search(r"\bReader\s*::\s*new\s*\(", code) is not None + ) + ) + + def def_context(name: str) -> str: """Name a definition as a member would be named, for parent context.""" @@ -1668,7 +1682,7 @@ def rule(name: str, at: str, message: str, fix: str) -> None: reader = entry["reader"] if isinstance(reader, dict) and entry["syntax"] in ("yaml", "json") and self.applies(entry, "CFG-YAML-1"): text = self.rust_text(str(reader["file"])) or "" - if "registry_platform_yaml" not in text and "RuntimeConfigLoader" not in text: + if not reads_through_platform_yaml(str(reader["crate"]), text): self.find("CFG-YAML-1", fid, str(reader["file"]), f"{reader['function']}()", "the reader does not read through registry-platform-yaml", "Read through registry-platform-yaml") diff --git a/products/platform/scripts/test_check_config_conventions.py b/products/platform/scripts/test_check_config_conventions.py index 27396b5ae6..d7751e2f18 100644 --- a/products/platform/scripts/test_check_config_conventions.py +++ b/products/platform/scripts/test_check_config_conventions.py @@ -1505,6 +1505,22 @@ def test_cfg_yaml_1_reports_a_reader_outside_the_shared_reader(self) -> None: self.repo.write(CONFIG_RS, CONFIG_RS_TEXT.replace("registry_platform_yaml", "serde_yaml_ng")) self.assertFinding(self.repo.run(), "CFG-YAML-1", P, at(CONFIG_RS, "load()")) + def test_cfg_yaml_1_recognizes_reader_new_inside_platform_yaml_itself(self) -> None: + self.assertTrue( + lint.reads_through_platform_yaml( + "registry-platform-yaml", + "pub fn read(file: &str) { Reader::new(file).scan(b\"\"); }", + ) + ) + + def test_cfg_yaml_1_does_not_treat_bare_reader_new_as_shared_in_another_crate(self) -> None: + self.assertFalse( + lint.reads_through_platform_yaml( + "registry-casework-core", + "pub fn read(file: &str) { Reader::new(file).scan(b\"\"); }", + ) + ) + class SourceLintTests(ConventionsTestCase): def plant(self, text: str) -> None: diff --git a/products/render/CHANGELOG.md b/products/render/CHANGELOG.md index f3a8016a73..9ba8099070 100644 --- a/products/render/CHANGELOG.md +++ b/products/render/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- `registry-render check --bundle` reads an optional + `check-acceptances.yaml`. An exact, reasoned acceptance keeps its warning + visible while allowing `--deny-warnings` to pass; errors remain refusals. - Add shared OIDC resource-token authentication to the serving adapter, with `render:render` scope and cached signing-key readiness. Legacy API keys remain available. Audit callers fingerprint the exact verified credential. diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md index 8b82c0e575..fb54b92419 100644 --- a/products/scheduling/CHANGELOG.md +++ b/products/scheduling/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- `schedulingctl check` reads an optional project `check-acceptances.yaml`, + keeps exactly matched warnings visible with their reasons, and excludes only + those accepted warnings from `--deny-warnings`. - Split-mode refusals for a runtime role that owns Scheduling objects name an exact `ALTER ... OWNER TO` statement for each Scheduling schema object, then `schedulingctl apply`, in place of `REASSIGN OWNED BY`, which also moves the