diff --git a/crates/registry-breg/src/api/tests/immediate_action_tests.rs b/crates/registry-breg/src/api/tests/immediate_action_tests.rs index e4b69c4193..82d56fc59a 100644 --- a/crates/registry-breg/src/api/tests/immediate_action_tests.rs +++ b/crates/registry-breg/src/api/tests/immediate_action_tests.rs @@ -858,9 +858,11 @@ async fn human_identity_is_the_principal_claim_and_a_human_token_without_it_is_r let registry = compiled(); let idp = MockIdp::start().await; let audience = "urn:example:breg"; + let mut verifier = oidc_verifier_config(idp.issuer(), vec![audience.to_owned()]); + verifier.allowed_clients = vec!["agent-client".to_owned()]; let auth = RegistryAuthenticator::new( ®istry, - oidc_verifier_config(idp.issuer(), vec![audience.to_owned()]), + verifier, Arc::new(JwksFetcher::new_with_fetch_url_policy( idp.jwks_uri(), JwksFetcherConfig::defaults(), @@ -871,6 +873,7 @@ async fn human_identity_is_the_principal_claim_and_a_human_token_without_it_is_r .expect("human verifier config is valid"); let claims = json!({ "aud": audience, + "azp": "agent-client", "registry_principal": "reviewer-principal", "registry_actor_kind": "human", "registry_purpose": "case-management", diff --git a/crates/registry-breg/src/auth.rs b/crates/registry-breg/src/auth.rs index f1fce5b5ba..f99e136060 100644 --- a/crates/registry-breg/src/auth.rs +++ b/crates/registry-breg/src/auth.rs @@ -625,6 +625,26 @@ fn validate_claim_mapping( return Err(AuthenticationConfigError::InvalidClaimMapping); } } + if registry.actions().actions.iter().any(|action| { + action.permissions.iter().any(|profile| { + profile + .requester_clients + .iter() + .any(|client| !verifier.allowed_clients.contains(client)) + }) || match &action.execution { + crate::model::CompiledActionExecution::Immediate => false, + crate::model::CompiledActionExecution::Proposal { proposal } => { + proposal.permissions.iter().any(|profile| { + profile + .requester_clients + .iter() + .any(|client| !verifier.allowed_clients.contains(client)) + }) + } + } + }) { + return Err(AuthenticationConfigError::NamedClientNotListed); + } // A recipient's client that the verifier would refuse can never read under // consent, so a mismatch is a configuration error, not a silent denial. if registry.recipients().clients().any(|client| { diff --git a/crates/registry-breg/tests/http_auth.rs b/crates/registry-breg/tests/http_auth.rs index c50d4d2ef3..93c4a4cfea 100644 --- a/crates/registry-breg/tests/http_auth.rs +++ b/crates/registry-breg/tests/http_auth.rs @@ -2631,3 +2631,98 @@ async fn dataset_only_client_and_standing_actor_mapping_are_checked_at_construct authenticator_with_verifier(®istry, &idp, verifier, claims) .expect("the dataset-only standing agent has an exact trusted actor mapping"); } + +#[tokio::test] +async fn action_and_proposal_only_clients_are_checked_at_construction() { + let idp = MockIdp::start().await; + let mut action_source = action_only_claim_source(); + action_source["accessProfiles"][0]["actorKind"] = json!("service"); + action_source["accessProfiles"][0]["requesterClients"] = json!(["workflow-client"]); + let action_registry = compile_action_claim_source(&action_source); + assert!(action_registry + .entities() + .values() + .all(|entity| entity.access_profiles.is_empty())); + assert!(action_registry.actions().actions[0].permissions[0] + .requester_clients + .contains("workflow-client")); + + let mut verifier = verifier_config(&idp); + verifier.allowed_clients = vec!["another-client".into()]; + assert_eq!( + authenticator_with_verifier(&action_registry, &idp, verifier.clone(), authority_claims()) + .unwrap_err(), + AuthenticationConfigError::NamedClientNotListed + ); + verifier.allowed_clients = vec!["workflow-client".into()]; + authenticator_with_verifier(&action_registry, &idp, verifier.clone(), authority_claims()) + .expect("the admitted action-only service client is accepted"); + + let mut proposal_source = action_source; + proposal_source["accessProfiles"][0]["actorKind"] = json!("agent"); + proposal_source["actions"][0]["execution"] = json!({ + "mode": "proposal", + "proposal": { + "review": {"type": "none"}, + "application": {"mode": "manual"}, + "retention": {"mode": "retain"} + } + }); + let mut starter = proposal_source["accessProfiles"][0].clone(); + starter["id"] = json!("starter"); + starter + .as_object_mut() + .expect("the starter profile is an object") + .remove("actorKind"); + starter + .as_object_mut() + .expect("the starter profile is an object") + .remove("requesterClients"); + starter["permissions"]["actions"][0]["operations"] = json!(["start"]); + proposal_source["accessProfiles"][0]["permissions"] = json!({ + "proposals": [{ + "action": "register-child", + "operations": ["edit"], + "ownership": "caller", + "targets": [ + {"recordType": "parent", "rowBoundaries": "unrestricted"}, + {"recordType": "child", "rowBoundaries": "unrestricted"} + ] + }] + }); + proposal_source["accessProfiles"] + .as_array_mut() + .expect("access profiles are a list") + .push(starter); + let proposal_registry = compile_action_claim_source(&proposal_source); + assert!(proposal_registry.actions().actions[0] + .permissions + .iter() + .all(|profile| profile.profile_id != "registrar")); + let proposal = match &proposal_registry.actions().actions[0].execution { + registry_breg::model::CompiledActionExecution::Proposal { proposal } => proposal, + registry_breg::model::CompiledActionExecution::Immediate => { + panic!("the fixture compiles as a Proposal") + } + }; + assert!(proposal + .permissions + .iter() + .any(|profile| profile.profile_id == "registrar" + && profile.requester_clients.contains("workflow-client"))); + + verifier.allowed_clients = vec!["another-client".into()]; + assert_eq!( + authenticator_with_verifier( + &proposal_registry, + &idp, + verifier.clone(), + authority_claims() + ) + .unwrap_err(), + AuthenticationConfigError::NamedClientNotListed + ); + verifier.allowed_clients = vec!["workflow-client".into()]; + authenticator_with_verifier(&proposal_registry, &idp, verifier, authority_claims()) + .expect("an admitted Proposal-only standing agent needs no trusted actor mapping"); +} diff --git a/crates/registry-bregctl/tests/cli.rs b/crates/registry-bregctl/tests/cli.rs index 3764f4346f..bb2c986840 100644 --- a/crates/registry-bregctl/tests/cli.rs +++ b/crates/registry-bregctl/tests/cli.rs @@ -2964,22 +2964,29 @@ fn check_refuses_a_runtime_configuration_in_the_reader_words() { } /// The minimal project with its reader profile limited to the client `portal`. -fn minimal_project_limited_to_portal(scratch: &TestProject) { +fn minimal_project_limited_to_portal(scratch: &TestProject, include_record_permissions: bool) { let example = minimal_example(); - let registry = fs::read_to_string(example.join("registry.yaml")) - .expect("the minimal registry reads") - .replace( - " requiredPurposes: [directory-administration]\n", - " requiredPurposes: [directory-administration]\n actorKind: service\n requesterClients: [portal]\n", - ); - assert!(registry.contains("requesterClients: [portal]")); + let mut registry = registry_platform_yaml::Reader::new("registry.yaml") + .scan(&fs::read(example.join("registry.yaml")).expect("the minimal registry reads")) + .unwrap() + .unwrap() + .to_json_value(); + registry["accessProfiles"][0]["actorKind"] = json!("service"); + registry["accessProfiles"][0]["requesterClients"] = json!(["portal"]); + if !include_record_permissions { + registry["accessProfiles"][0]["permissions"] + .as_object_mut() + .unwrap() + .remove("records"); + } + let registry = serde_norway::to_string(®istry).expect("the edited registry encodes"); fs::write(scratch.path().join("registry.yaml"), registry).expect("the registry writes"); } #[test] fn check_refuses_a_runtime_that_lists_none_of_the_clients_a_profile_names() { let scratch = TestProject::from_registry_source(b""); - minimal_project_limited_to_portal(&scratch); + minimal_project_limited_to_portal(&scratch, true); let runtime = edited_runtime_config(&scratch, |source| { source.replace( "allowedClients: [directory-client]", @@ -3023,6 +3030,51 @@ fn check_refuses_a_runtime_that_lists_none_of_the_clients_a_profile_names() { assert!(!human_output(&human).contains("portal"), "{human:?}"); } +#[test] +fn check_refuses_unlisted_clients_on_action_and_proposal_only_profiles() { + let scratch = TestProject::from_registry_source(b""); + minimal_project_limited_to_portal(&scratch, false); + let refused_runtime = edited_runtime_config(&scratch, |source| { + source.replace( + "allowedClients: [directory-client]", + "allowedClients: unrestricted", + ) + }); + + let refused = bregctl(&[ + "--format", + "json", + "check", + path(scratch.path()), + "--runtime-config", + path(&refused_runtime), + ]); + assert_eq!(refused.status.code(), Some(1), "{refused:?}"); + let report = json_stdout(&refused); + let diagnostics = report["diagnostics"].as_array().expect("diagnostics list"); + assert_eq!(diagnostics.len(), 1, "{report}"); + assert_eq!(diagnostics[0]["code"], "breg.runtime.clients-unlisted"); + assert_eq!( + diagnostics[0]["path"], + "/authentication/oidc/allowedClients" + ); + assert!(!report.to_string().contains("portal"), "{report}"); + + let accepted_runtime = edited_runtime_config(&scratch, |source| { + source.replace( + "allowedClients: [directory-client]", + "allowedClients: [portal]", + ) + }); + let accepted = bregctl(&[ + "check", + path(scratch.path()), + "--runtime-config", + path(&accepted_runtime), + ]); + assert_eq!(accepted.status.code(), Some(0), "{accepted:?}"); +} + #[test] fn check_refuses_a_principal_claim_the_access_profiles_do_not_use() { let scratch = TestProject::from_registry_source(b""); @@ -3158,7 +3210,7 @@ fn check_warns_of_a_wildcard_spelled_allowed_client_and_denies_it_on_request() { #[test] fn check_accepts_a_runtime_that_lists_the_clients_a_profile_names() { let scratch = TestProject::from_registry_source(b""); - minimal_project_limited_to_portal(&scratch); + minimal_project_limited_to_portal(&scratch, true); let runtime = edited_runtime_config(&scratch, |source| { source.replace( "allowedClients: [directory-client]", diff --git a/crates/registry-cli-docs/catalog.json b/crates/registry-cli-docs/catalog.json index 36b4800db9..e8f3dfab74 100644 --- a/crates/registry-cli-docs/catalog.json +++ b/crates/registry-cli-docs/catalog.json @@ -19065,6 +19065,95 @@ "constraints": [], "subcommands": [] }, + { + "name": "generate", + "invocation": "messagingctl generate", + "about": "Generate bounded contracts for another tool from an editable project", + "long_about": null, + "usage": "messagingctl generate [OPTIONS] ", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ + { + "name": "templates", + "invocation": "messagingctl generate templates", + "about": "Write every checked template version's locale and data contract", + "long_about": null, + "usage": "messagingctl generate templates [OPTIONS] --project --output ", + "arguments": [], + "options": [ + { + "display": "--project ", + "description": "Editable Messaging project holding messaging.yaml", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--output ", + "description": "New JSON file to create. Existing files are never overwritten", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + } + ] + }, { "name": "check", "invocation": "messagingctl check", diff --git a/crates/registry-coordinator/Cargo.toml b/crates/registry-coordinator/Cargo.toml index 7d59f9fbe0..38ea3af95a 100644 --- a/crates/registry-coordinator/Cargo.toml +++ b/crates/registry-coordinator/Cargo.toml @@ -63,6 +63,11 @@ name = "recovery_audit" path = "tests/recovery_audit.rs" required-features = ["postgres-test"] +[[test]] +name = "read_lease_recovery" +path = "tests/read_lease_recovery.rs" +required-features = ["postgres-test"] + [[test]] name = "process_restart" path = "tests/process_restart.rs" diff --git a/crates/registry-coordinator/src/authoring.rs b/crates/registry-coordinator/src/authoring.rs index 4b6e1240c7..fe62d3cdef 100644 --- a/crates/registry-coordinator/src/authoring.rs +++ b/crates/registry-coordinator/src/authoring.rs @@ -109,6 +109,7 @@ struct ImportedContract { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] enum ContractProduct { Breg, + Messaging, } #[derive(Debug, Deserialize)] @@ -121,6 +122,26 @@ struct ActionReference { id: LocalId, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +struct RecordReference { + /// The local generated BReg record schema import. + contract: LocalId, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +struct TemplateReference { + /// The local generated Messaging template contract import. + contract: LocalId, + /// The exact template ID in the imported contract. + id: LocalId, + /// The exact template version label, quoted when numeric. + version: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -133,6 +154,13 @@ struct AuthoredOperation { /// An exact BReg Action specialization from one generated contract import. #[serde(default)] action: Option, + /// Limit BReg read replies to the imported record schema's API field names. + /// Fields may be hidden by access policy, and their value types remain open. + #[serde(default)] + record: Option, + /// Fix a Messaging template version, its locales and its data schema. + #[serde(default)] + template: Option, /// An optional typed response specialization for the bounded external GET /// or external command Operation. #[serde(default)] @@ -903,7 +931,6 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { let root = path.parent().unwrap_or_else(|| Path::new(".")); let mut imported = BTreeMap::new(); for (id, contract) in project.contracts { - let _product = contract.product; let asset = asset_path(root, &contract.source) .map_err(|error| error.at("workflow.yaml", format!("/contracts/{id}/source")))?; let bytes = definition::read_bytes(&asset, 1_048_576) @@ -920,6 +947,21 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { "the generated contract must be strict JSON", ) })?; + let matches_product = match contract.product { + ContractProduct::Breg => { + crate::contracts::is_breg_record_schema(&value) + || (value.get("apiVersion").and_then(Value::as_str) + == Some("id.registrystack.org/formats/breg/action-contracts/v1") + && value.get("kind").and_then(Value::as_str) == Some("BRegActionContracts")) + } + ContractProduct::Messaging => crate::contracts::is_messaging_template_export(&value), + }; + if !matches_product { + return Err(CoordinatorError::new( + "coordinator.operation.contract-invalid", + "select a generated contract from the declared product: a BReg Action export or record schema, or a Messaging template export", + ).at("workflow.yaml", format!("/contracts/{id}/source"))); + } imported.insert(id.into_string(), value); } let raw_types = project @@ -967,15 +1009,62 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { } } } + for (selected, expected, field) in [ + ( + operation.record.as_ref().map(|value| &value.contract), + Operation::ReadRecord, + "record", + ), + ( + operation.template.as_ref().map(|value| &value.contract), + Operation::SubmitMessage, + "template", + ), + ] { + if let Some(selected) = selected { + if uses != expected { + return Err(schema_error( + format!("/operations/{id}/{field}"), + &format!( + "only {} accepts a {field} contract selection", + expected.as_str() + ), + )); + } + if !imported.contains_key(selected.as_str()) { + return Err(schema_error( + format!("/operations/{id}/{field}/contract"), + "select a declared local contract import", + )); + } + } + } let contract_field = operation.action.as_ref().map_or_else( || format!("/operations/{id}/action"), |action| format!("/contracts/{}/source", action.contract), ); + let contract_field = if operation.record.is_some() { + format!("/operations/{id}/record") + } else if operation.template.is_some() { + format!("/operations/{id}/template") + } else { + contract_field + }; let action = operation .action .map(|action| json!({"contract":action.contract.as_str(),"id":action.id.as_str()})); - let contract = crate::contracts::resolve_operation(uses, action.as_ref(), &imported) - .map_err(|error| error.at("workflow.yaml", contract_field))?; + let record = operation + .record + .map(|record| json!({"contract":record.contract.as_str()})); + let template = operation.template.map(|template| json!({"contract":template.contract.as_str(),"id":template.id.as_str(),"version":template.version})); + let contract = crate::contracts::resolve_operation( + uses, + action.as_ref(), + record.as_ref(), + template.as_ref(), + &imported, + ) + .map_err(|error| error.at("workflow.yaml", contract_field))?; if operation.response_schema.is_some() && !matches!(uses, Operation::ExternalGet | Operation::ExternalCommand) { @@ -1432,7 +1521,12 @@ fn reference_sites(document: &Document) -> BTreeMap { + ("contract", Some(id)) + if !schema + && ["/action", "/record", "/template"] + .iter() + .any(|suffix| path.ends_with(suffix)) => + { Some(format!("/contracts/{id}")) } ("source", Some(asset)) diff --git a/crates/registry-coordinator/src/cli.rs b/crates/registry-coordinator/src/cli.rs index 6a86a4b232..32d30d2b32 100644 --- a/crates/registry-coordinator/src/cli.rs +++ b/crates/registry-coordinator/src/cli.rs @@ -1307,7 +1307,7 @@ fn render_human(cli: &Cli, value: &Value) -> String { } } -/// One line per step in graph order from start, then the execution bounds. +/// One line per step in graph order, the event triggers, then execution bounds. fn render_explain(value: &Value) -> String { let listed = |values: &Value| { values @@ -1374,6 +1374,13 @@ fn render_explain(value: &Value) -> String { }; out.push_str(&format!(" {}: {line}\n", text(&step["id"]))); } + for (id, trigger) in value["eventTriggers"].as_object().into_iter().flatten() { + out.push_str(&format!( + "Trigger {id}: {} {}\n", + text(&trigger["source"]), + text(&trigger["eventType"]) + )); + } let limits = value["limits"] .as_object() .into_iter() diff --git a/crates/registry-coordinator/src/contracts.rs b/crates/registry-coordinator/src/contracts.rs index 0aad0c2082..950abe4803 100644 --- a/crates/registry-coordinator/src/contracts.rs +++ b/crates/registry-coordinator/src/contracts.rs @@ -314,8 +314,22 @@ pub(crate) fn bound_numbers(value: &mut Value) { pub fn resolve_operation( operation: Operation, action: Option<&Value>, + record: Option<&Value>, + template: Option<&Value>, imported: &BTreeMap, ) -> crate::Result { + if let Some(record) = record { + if operation != Operation::ReadRecord || action.is_some() || template.is_some() { + return Err(record_contract_invalid()); + } + return resolve_record(record, imported); + } + if let Some(template) = template { + if operation != Operation::SubmitMessage || action.is_some() { + return Err(template_contract_invalid()); + } + return resolve_template(template, imported); + } let invalid = || { crate::CoordinatorError::new( "coordinator.operation.contract-invalid", @@ -418,6 +432,139 @@ pub fn resolve_operation( ) } +pub(crate) fn is_breg_record_schema(value: &Value) -> bool { + value.get("$id").and_then(Value::as_str).is_some_and(|id| { + id.strip_prefix("urn:breg:record-type:") + .is_some_and(|id| !id.is_empty()) + }) && value.get("type").and_then(Value::as_str) == Some("object") + && value.get("properties").is_some_and(Value::is_object) +} + +pub(crate) fn is_messaging_template_export(value: &Value) -> bool { + value.get("apiVersion").and_then(Value::as_str) + == Some("id.registrystack.org/formats/messaging/template-contracts/v1") + && value.get("kind").and_then(Value::as_str) == Some("MessagingTemplateContracts") +} + +fn record_contract_invalid() -> crate::CoordinatorError { + crate::CoordinatorError::new( + "coordinator.operation.contract-invalid", + "select a generated BReg record schema for breg.read-record/v1 with record: {contract: }", + ) +} + +fn resolve_record( + reference: &Value, + imported: &BTreeMap, +) -> crate::Result { + let source = reference + .get("contract") + .and_then(Value::as_str) + .ok_or_else(record_contract_invalid)?; + let schema = imported + .get(source) + .filter(|value| is_breg_record_schema(value)) + .ok_or_else(record_contract_invalid)?; + let properties = schema["properties"] + .as_object() + .ok_or_else(record_contract_invalid)?; + let base = contract(Operation::ReadRecord); + let mut output = base.output.clone(); + // Reads retain current disclosure: required fields may be hidden, and + // this specialization promises API names rather than value validation. + output["$defs"]["RegistryRecord"]["properties"]["domainData"] = json!({ + "type": "object", "additionalProperties": false, + "properties": properties.keys().map(|name| (name.clone(), Value::Bool(true))).collect::>() + }); + OperationContract::new( + base.input.clone(), + output, + base.declared_refusal.clone(), + None, + ) +} + +fn template_contract_invalid() -> crate::CoordinatorError { + crate::CoordinatorError::new( + "coordinator.operation.contract-invalid", + "select exactly one template ID and version from a generated Messaging template contract export", + ) +} + +fn resolve_template( + reference: &Value, + imported: &BTreeMap, +) -> crate::Result { + let source = reference + .get("contract") + .and_then(Value::as_str) + .ok_or_else(template_contract_invalid)?; + let id = reference + .get("id") + .and_then(Value::as_str) + .ok_or_else(template_contract_invalid)?; + let version = reference + .get("version") + .and_then(Value::as_str) + .ok_or_else(template_contract_invalid)?; + let export = imported + .get(source) + .filter(|value| is_messaging_template_export(value)) + .ok_or_else(template_contract_invalid)?; + let mut entries = export + .get("templates") + .and_then(Value::as_array) + .ok_or_else(template_contract_invalid)? + .iter() + .filter(|entry| { + entry.get("id").and_then(Value::as_str) == Some(id) + && entry.get("version").and_then(Value::as_str) == Some(version) + }); + let entry = entries.next().ok_or_else(template_contract_invalid)?; + if entries.next().is_some() { + return Err(template_contract_invalid()); + } + let locales = entry + .get("locales") + .and_then(Value::as_array) + .filter(|locales| { + !locales.is_empty() + && locales + .iter() + .all(|locale| locale.as_str().is_some_and(|locale| !locale.is_empty())) + }) + .ok_or_else(template_contract_invalid)?; + let mut data = entry + .get("dataSchema") + .cloned() + .ok_or_else(template_contract_invalid)?; + if let Some(object) = data.as_object_mut() { + object.remove("$id"); + object.remove("$schema"); + } + bound_numbers(&mut data); + let data = crate::authoring::embed_schema(&data, "/$defs/templateData")?; + let base = contract(Operation::SubmitMessage); + let mut input = base.input.clone(); + input + .as_object_mut() + .expect("operation input is an object") + .remove("oneOf"); + input["$defs"]["templateData"] = data; + input["properties"]["template"] = json!({"type": "object", "additionalProperties": false, + "required": ["id", "version"], "properties": {"id": {"const": id}, "version": {"const": version}}}); + input["properties"]["locale"] = json!({"type": "string", "enum": locales}); + input["properties"]["data"] = json!({"$ref": "#/$defs/templateData", "not": {"type": "null"}}); + input["properties"]["content"] = json!({"type": "null"}); + input["required"] = json!(["senderProfile", "to", "template", "locale", "data"]); + OperationContract::new( + input, + base.output.clone(), + base.declared_refusal.clone(), + None, + ) +} + pub(crate) fn first_violation(validator: &JSONSchema, value: &Value) -> Result<(), Violation> { if crate::functions::check_value(value).is_err() { return Err(Violation { @@ -638,9 +785,9 @@ pub fn generate() -> Result, serde_json::Error> { if descriptor.id == Operation::ReadRecord.as_str() { output["$defs"]["RegistryRecord"]["properties"]["domainData"]["description"] = Value::from( - "Project-defined record data. The registry project's entity schema owns \ - these members, so check accepts any path beneath domainData and test \ - and the runtime decide.", + "Project-defined record data. An imported BReg record schema limits member \ + names to the record type's API names. Fields may be hidden by access policy; \ + value types remain open. Without a record import, any member name is accepted.", ); } for (role, schema) in [("input", input), ("output", output)] { @@ -1447,14 +1594,23 @@ mod tests { .unwrap(); let reference = json!({"contract":"applications","id":"request-information"}); let imported = BTreeMap::from([("applications".to_owned(), export.clone())]); - assert!( - resolve_operation(Operation::InvokeBregAction, Some(&reference), &imported).is_ok() - ); + assert!(resolve_operation( + Operation::InvokeBregAction, + Some(&reference), + None, + None, + &imported + ) + .is_ok()); export["actions"][0]["mode"] = json!("proposal"); let imported = BTreeMap::from([("applications".to_owned(), export)]); - let Err(error) = - resolve_operation(Operation::InvokeBregAction, Some(&reference), &imported) - else { + let Err(error) = resolve_operation( + Operation::InvokeBregAction, + Some(&reference), + None, + None, + &imported, + ) else { panic!("a Proposal-mode Action resolved"); }; assert_eq!(error.code, "coordinator.operation.proposal-action"); diff --git a/crates/registry-coordinator/src/definition.rs b/crates/registry-coordinator/src/definition.rs index fb528cdfce..ae7edc9a78 100644 --- a/crates/registry-coordinator/src/definition.rs +++ b/crates/registry-coordinator/src/definition.rs @@ -784,7 +784,7 @@ impl Definition { Ok( serde_json::json!({"workflow":self.workflow.id,"version":self.workflow.version,"workflowRevision":self.digest, "start":self.workflow.start,"deadlineSeconds":parse_duration(&self.workflow.deadline)?.num_seconds(), - "contracts":contracts,"operations":self.workflow.operations,"operationContracts":operation_contracts,"functions":self.workflow.functions,"types":self.workflow.types,"references":self.workflow.references,"presentation":self.workflow.presentation,"connections":self.workflow.connections,"outcomes":self.workflow.outcomes.keys().collect::>(),"steps":steps, + "contracts":contracts,"operations":self.workflow.operations,"operationContracts":operation_contracts,"eventTriggers":self.workflow.event_triggers,"functions":self.workflow.functions,"types":self.workflow.types,"references":self.workflow.references,"presentation":self.workflow.presentation,"connections":self.workflow.connections,"outcomes":self.workflow.outcomes.keys().collect::>(),"steps":steps, "limits":crate::functions::limits(),"valueLimits":crate::functions::value_limits(),"interpreterAbi":self.frozen.interpreter_abi,"schemaAbi":self.frozen.schema_abi,"adapterAbi":self.frozen.adapter_abi, "recovery":"A run restores this exact snapshot. retry-same preserves a frozen command and cannot adopt edited mappings or repeat an uncertain hold-after-dispatch evaluation."}), diff --git a/crates/registry-coordinator/src/store.rs b/crates/registry-coordinator/src/store.rs index e346d4c77b..b9e9e460a4 100644 --- a/crates/registry-coordinator/src/store.rs +++ b/crates/registry-coordinator/src/store.rs @@ -2131,7 +2131,7 @@ pub(crate) const RETRY_MAXIMUM_DELAY: Duration = /// wait clears the deadline; otherwise the worker holds the step as unknown, /// so the same command can still be retried. Pure steps call no product and /// carry no deadline of their own. -fn policy(job: &Job) -> JobPolicy { +fn policy(job: &Job, on_uncertain: UncertainOutcome) -> JobPolicy { // A task-grant read can use four sequential two-second HTTP exchanges. // Dispatch owns the additional five-second lease finalization allowance. JobPolicy { @@ -2143,7 +2143,7 @@ fn policy(job: &Job) -> JobPolicy { multiplier: 2, jitter: Jitter::Equal, }), - on_uncertain: UncertainOutcome::Hold, + on_uncertain, expires_at: (!job.pure).then(|| job.deadline_at.into()), } } @@ -2217,7 +2217,7 @@ impl DispatchStore for Store { ) -> std::result::Result, ClaimRefusal> { let job = decode(row, first).map_err(|_| ClaimRefusal::Unavailable)?; Ok(Decoded { - policy: policy(&job), + policy: policy(&job, UncertainOutcome::Hold), job, }) } @@ -2230,8 +2230,24 @@ impl DispatchStore for Store { first: usize, ) -> std::result::Result, DispatchError> { let job = decode(row, first)?; + let run: Uuid = row.try_get(0)?; + let step: String = row.try_get(1)?; + let snapshot: String = self + .open_value(run, "snapshot", "", &job.snapshot) + .map_err(|_| DispatchError::Unavailable)?; + let definition = self + .definition(&snapshot) + .map_err(|_| DispatchError::Unavailable)?; + let on_uncertain = match definition.workflow.steps.get(&step) { + Some(Step::Call { call, .. }) + if call.operation.recovery() == crate::protocol::RecoverySemantics::ReadAgain => + { + UncertainOutcome::RetryThenHold + } + _ => UncertainOutcome::Hold, + }; Ok(Decoded { - policy: policy(&job), + policy: policy(&job, on_uncertain), job, }) } @@ -2515,6 +2531,11 @@ impl DispatchStore for Store { || audit.record.cancel_requested && !uncertain { "cancelled" + } else if matches!( + audit.transition, + Transition::LeaseLapsed(Disposition::RetryPending) + ) { + "running" } else if !uncertain && matches!(audit.transition, Transition::Expired { .. }) { "expired" } else { @@ -2735,14 +2756,14 @@ mod tests { #[test] fn call_retries_back_off_exponentially_inside_the_run_deadline() { - let call = policy(&job(false)); + let call = policy(&job(false), UncertainOutcome::Hold); assert_eq!(call.maximum_attempts, 10); assert_eq!( call.expires_at, Some(SystemTime::from(job(false).deadline_at)), "no retry may be scheduled at or after the run deadline" ); - assert_eq!(policy(&job(true)).expires_at, None); + assert_eq!(policy(&job(true), UncertainOutcome::Hold).expires_at, None); // Equal jitter keeps each delay between half its base and the whole // base. The sample is the only varying input, so a fixed sample gives // a fixed delay. @@ -2770,7 +2791,7 @@ mod tests { #[test] fn a_retry_after_hint_is_a_floor_bounded_by_the_longest_delay() { - let retry = policy(&job(false)).retry; + let retry = policy(&job(false), UncertainOutcome::Hold).retry; let delay = |hint: u64| { retry .delay_after(1, Some(Duration::from_secs(hint)), 0) diff --git a/crates/registry-coordinator/tests/postgres_state.rs b/crates/registry-coordinator/tests/postgres_state.rs index 11fd35ea5b..3c6516cbb3 100644 --- a/crates/registry-coordinator/tests/postgres_state.rs +++ b/crates/registry-coordinator/tests/postgres_state.rs @@ -4147,7 +4147,7 @@ async fn restored_current_job(h: &Harness, run: Uuid) -> Value { } #[tokio::test] -async fn control_lock_timeout_preserves_the_prepared_read_for_explicit_recovery() { +async fn control_lock_timeout_reschedules_the_prepared_read_without_losing_its_command() { let h = invalid_following_wait("read", "input.at").await; let run = h.admit().await; // Only the worker connection has this bound. The ordinary control SELECT @@ -4191,11 +4191,12 @@ async fn control_lock_timeout_preserves_the_prepared_read_for_explicit_recovery( assert!(h.store.status(run).await.unwrap().deadline_at > Utc::now()); let identity = restored_lease_identity(&h, run).await; - // Dispatch retains the lease on transport error. Its existing Hold policy - // reaps the lapse as Unknown, without automatically claiming or sending. + // Dispatch retains the lease on transport error. The pinned read-again + // operation reaps the lapse to the ordinary retry schedule without + // claiming or sending in the recovery tick. h.sql("UPDATE {schema}.jobs SET attempt_started_at=clock_timestamp()-interval '60 seconds',lease_expires_at=clock_timestamp()-interval '1 second' WHERE state='leased'").await; assert!(!worker.tick().await.unwrap()); - assert_eq!(restored_current_job(&h, run).await["state"], "unknown"); + assert_eq!(restored_current_job(&h, run).await["state"], "pending"); assert_eq!(restored_lease_identity(&h, run).await, identity); assert!(fake.requests.lock().unwrap().is_empty()); let operator = actor("operator", true); @@ -4211,17 +4212,12 @@ async fn control_lock_timeout_preserves_the_prepared_read_for_explicit_recovery( .release_restore_hold(&operator, "complete-history-fenced") .await .unwrap(); - assert_eq!( - restored_current_job(&h, run).await["state"], - "dead-lettered" - ); + let retry = restored_current_job(&h, run).await; + assert_eq!(retry["state"], "pending"); + assert_eq!(retry["command"], job["command"]); assert_eq!(restored_lease_identity(&h, run).await, identity); assert!(fake.requests.lock().unwrap().is_empty()); - h.store.retry_same(run, "binding-a").await.unwrap(); - let replay = restored_current_job(&h, run).await; - assert_eq!(replay["state"], "pending"); - assert_eq!(replay["command"], job["command"]); - assert_eq!(replay["uncertain"], false); + h.sql("UPDATE {schema}.jobs SET next_attempt_at=clock_timestamp()-interval '1 second' WHERE state='pending'").await; assert!(worker.tick().await.unwrap()); assert_eq!(fake.requests.lock().unwrap().len(), 1); } @@ -4295,7 +4291,7 @@ impl AdapterSet for PausedRecoveryRead { } #[tokio::test] -async fn restored_expired_read_fences_paused_completion_without_reclaiming_or_reexecuting() { +async fn restore_hold_blocks_rescheduled_read_until_release_and_fences_paused_completion() { for already_reaped in [false, true] { let h = invalid_following_wait("read", "input.at").await; let run = h.admit().await; @@ -4311,6 +4307,7 @@ async fn restored_expired_read_fences_paused_completion_without_reclaiming_or_re }); fake.entered.notified().await; h.sql("UPDATE {schema}.jobs SET attempt_started_at=clock_timestamp()-interval '60 seconds',lease_expires_at=clock_timestamp()-interval '1 second' WHERE state='leased'").await; + let identity = restored_lease_identity(&h, run).await; if already_reaped { assert!( !worker.tick().await.unwrap(), @@ -4325,7 +4322,7 @@ async fn restored_expired_read_fences_paused_completion_without_reclaiming_or_re .last() .unwrap() .state, - "unknown" + "pending" ); assert!(!h.store.status(run).await.unwrap().uncertain); } @@ -4334,18 +4331,23 @@ async fn restored_expired_read_fences_paused_completion_without_reclaiming_or_re .set_restore_hold(&operator, "paused-prior-worker") .await .unwrap(); - let identity = restored_lease_identity(&h, run).await; + for _ in 0..2 { + assert!( + !worker.tick().await.unwrap(), + "restore hold may reap a lapsed read but must not dispatch its retry" + ); + assert_eq!(restored_current_job(&h, run).await["state"], "pending"); + assert_eq!(restored_lease_identity(&h, run).await, identity); + assert_eq!(fake.requests.lock().unwrap().len(), 1); + } h.store .complete_execution_recovery(&operator, "complete-history-fenced", true, true) .await .unwrap(); - h.store - .release_restore_hold(&operator, "complete-history-fenced") - .await - .unwrap(); + assert_eq!(restored_current_job(&h, run).await["state"], "pending"); assert!( !worker.tick().await.unwrap(), - "settling a safe lease never enqueues work" + "complete recovery preserves the prepared retry without bypassing restore hold" ); fake.release.add_permits(1); assert!( @@ -4356,6 +4358,14 @@ async fn restored_expired_read_fences_paused_completion_without_reclaiming_or_re assert_eq!(h.store.status(run).await.unwrap().step, "read"); assert_eq!(fake.requests.lock().unwrap().len(), 1); assert!(h.output_values(run).await.is_empty()); + h.store + .release_restore_hold(&operator, "complete-history-fenced") + .await + .unwrap(); + h.sql("UPDATE {schema}.jobs SET next_attempt_at=clock_timestamp()-interval '1 second' WHERE state='pending'").await; + fake.release.add_permits(1); + assert!(worker.tick().await.unwrap()); + assert_eq!(fake.requests.lock().unwrap().len(), 2); } } diff --git a/crates/registry-coordinator/tests/product_contract_imports.rs b/crates/registry-coordinator/tests/product_contract_imports.rs new file mode 100644 index 0000000000..25b670da88 --- /dev/null +++ b/crates/registry-coordinator/tests/product_contract_imports.rs @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Owner-generated contracts constrain computed inputs and synthetic replies. +use registry_coordinator::{authoring, definition::Definition, scenarios}; +use serde_json::{json, Value}; +use std::{fs, path::Path, process::Command}; + +fn workflow(uses: &str) -> Value { + json!({ + "apiVersion": authoring::API_VERSION, "kind": authoring::KIND, + "project": {"id": "contract-imports", "version": "1"}, + "operations": {"request": {"uses": uses, "connection": "source"}}, + "workflow": { + "input": {"type": "object"}, "deadlineSeconds": 300, "start": "call", + "steps": { + "call": {"type": "call", "operation": "request", + "input": {"type": "input"}, "next": "done"}, + "done": {"type": "finish", "outcome": "accepted"} + }, + "outcomes": {"accepted": {"type": "null"}} + } + }) +} + +fn write_project(root: &Path, project: &Value, export: &Value, input: Value, reply: Value) { + fs::write(root.join("workflow.yaml"), project.to_string()).unwrap(); + fs::write(root.join("contract.json"), export.to_string()).unwrap(); + let cases = json!({ + "apiVersion": scenarios::API_VERSION, "kind": scenarios::KIND, + "cases": [{"id": "contracted", "admittedAt": "2026-10-09T09:00:00Z", "input": input, + "replies": {"call": [{"type": "success", "value": reply}]}, + "expect": {"path": ["call", "done"], "state": "completed", + "outcome": {"type": "named", "value": "accepted"}}}] + }); + fs::write(root.join("scenarios.yaml"), cases.to_string()).unwrap(); +} + +fn cli(root: &Path, command: &str) -> (i32, Value) { + let output = Command::new(env!("CARGO_BIN_EXE_coordinatorctl")) + .args(["--format", "json", command, "--project"]) + .arg(root) + .env_remove("COORDINATOR_TEST_DATABASE_URL") + .output() + .unwrap(); + let report = serde_json::from_slice(&output.stdout) + .unwrap_or_else(|_| panic!("{}", String::from_utf8_lossy(&output.stderr))); + (output.status.code().unwrap(), report) +} + +fn record_schema() -> Value { + json!({"$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:breg:record-type:licence", "type": "object", "additionalProperties": false, + "required": ["licenceNumber", "holder"], + "properties": {"licenceNumber": {"type": "string"}, "holder": {"type": "string"}}}) +} + +fn record(domain_data: Value) -> Value { + json!({"data": {"recordIdentifier": "r-1", "revisionIdentifier": "v-1", "domainData": domain_data}, + "meta": {"registryIdentifier": "registry", "datasetIdentifier": "licences", "entityTypeIdentifier": "licence"}}) +} + +#[test] +fn read_record_import_checks_names_without_requiring_visible_fields_or_value_types() { + let root = tempfile::tempdir().unwrap(); + let mut project = workflow("breg.read-record/v1"); + project["contracts"] = json!({"licence": {"type": "breg", "source": "contract.json"}}); + project["operations"]["request"]["record"] = json!({"contract": "licence"}); + let input = json!({"collection": "licences", "recordId": "r-1"}); + write_project( + root.path(), + &project, + &record_schema(), + input.clone(), + record(json!({"licence-number": "LIC-1"})), + ); + let definition = Definition::load(root.path()).unwrap(); + let cases = scenarios::load(&root.path().join("scenarios.yaml")).unwrap(); + let error = scenarios::check(&definition, &cases) + .err() + .expect("unknown record field must fail"); + assert_eq!(error.code, "scenario.reply-output"); + let (exit, report) = cli(root.path(), "test"); + assert_eq!(exit, 1, "{report}"); + assert_eq!( + report["diagnostics"][0]["code"], + "coordinator.scenario.reply-output" + ); + for data in [ + json!({"licenceNumber": "LIC-1"}), + json!({}), + json!({"licenceNumber": 7}), + ] { + write_project( + root.path(), + &project, + &record_schema(), + input.clone(), + record(data), + ); + let (exit, report) = cli(root.path(), "test"); + assert_eq!(exit, 0, "{report}"); + } + let snapshot = definition.snapshot().unwrap(); + let restored = Definition::from_snapshot(&snapshot).unwrap(); + assert_eq!(restored.digest, definition.digest); +} + +#[test] +fn read_record_selection_refuses_other_contracts_and_wrong_operations() { + let root = tempfile::tempdir().unwrap(); + let mut project = workflow("breg.read-record/v1"); + project["contracts"] = json!({"licence": {"type": "breg", "source": "contract.json"}}); + project["operations"]["request"]["record"] = json!({"contract": "licence"}); + for export in [ + json!({"type": "object", "properties": {}}), + json!({ + "apiVersion": "id.registrystack.org/formats/breg/action-contracts/v1", + "kind": "BRegActionContracts", "actions": []}), + ] { + write_project(root.path(), &project, &export, json!({}), record(json!({}))); + let (exit, report) = cli(root.path(), "check"); + assert_eq!(exit, 1, "{report}"); + assert_eq!( + report["diagnostics"][0]["code"], + "coordinator.operation.contract-invalid" + ); + } + project["operations"]["request"]["uses"] = json!("external.get/v1"); + write_project( + root.path(), + &project, + &record_schema(), + json!({}), + json!({}), + ); + let (exit, report) = cli(root.path(), "check"); + assert_eq!(exit, 1, "{report}"); + assert_eq!( + report["diagnostics"][0]["path"], + "/operations/request/record" + ); +} + +fn template_contract() -> Value { + json!({"apiVersion": "id.registrystack.org/formats/messaging/template-contracts/v1", + "kind": "MessagingTemplateContracts", "templates": [{"id": "licence-issued", "version": "1", + "locales": ["en", "fr"], "dataSchema": { + "type": "object", "additionalProperties": false, "required": ["name", "licenceNumber"], + "properties": {"name": {"type": "string"}, "licenceNumber": {"$ref": "#/$defs/number"}}, + "$defs": {"number": {"type": "string"}}}}]}) +} + +fn message(data: Value) -> Value { + json!({"senderProfile": "transactional", "to": {"email": "ada@example.invalid"}, + "template": {"id": "licence-issued", "version": "1"}, "locale": "en", "data": data}) +} + +fn receipt() -> Value { + json!({"id": "00000000-0000-4000-8000-000000000001", "status": "queued", + "links": {"self": "/v1/messages/00000000-0000-4000-8000-000000000001", + "cancel": "/v1/messages/00000000-0000-4000-8000-000000000001/cancel"}}) +} + +#[test] +fn template_import_checks_computed_data_and_keeps_local_schema_references() { + let root = tempfile::tempdir().unwrap(); + let mut project = workflow("messaging.submit-message/v1"); + project["contracts"] = json!({"notices": {"type": "messaging", "source": "contract.json"}}); + project["operations"]["request"]["template"] = + json!({"contract": "notices", "id": "licence-issued", "version": "1"}); + project["functions"] = json!({"message": {"runtime": "rhai", "source": "functions.rhai", "entrypoint": "message", + "inputs": {"input": {"type": "object"}}, "output": {"type": "object"}}}); + project["workflow"]["steps"]["call"]["input"] = + json!({"type": "function", "function": "message", "inputs": {"input": {"type": "input"}}}); + fs::write( + root.path().join("functions.rhai"), + "fn message(args) { args.input }", + ) + .unwrap(); + let reply = receipt(); + write_project( + root.path(), + &project, + &template_contract(), + message(json!({"name": "Ada", "licenceNo": "L1"})), + reply.clone(), + ); + let (exit, report) = cli(root.path(), "test"); + assert_eq!(exit, 1, "{report}"); + assert_eq!( + report["diagnostics"][0]["code"], + "coordinator.scenario.call-input" + ); + let good = message(json!({"name": "Ada", "licenceNumber": "L1"})); + write_project( + root.path(), + &project, + &template_contract(), + good.clone(), + reply, + ); + let (exit, report) = cli(root.path(), "test"); + assert_eq!(exit, 0, "{report}"); + let definition = Definition::load(root.path()).unwrap(); + let input = &definition.workflow.operations["request"].input; + let validator = registry_coordinator::contracts::OperationContract::new( + input.clone(), + json!({}), + None, + None, + ) + .unwrap(); + for bad in [ + { + let mut v = good.clone(); + v["locale"] = json!("de"); + v + }, + { + let mut v = good.clone(); + v["template"]["version"] = json!("2"); + v + }, + { + let mut v = good.clone(); + v["data"]["licenceNumber"] = json!(7); + v + }, + json!({"senderProfile": "transactional", "to": {"email": "ada@example.invalid"}, "content": {"text": "hello"}}), + ] { + assert!(validator.check_input(&bad).is_err(), "{bad}"); + } + let restored = Definition::from_snapshot(&definition.snapshot().unwrap()).unwrap(); + assert_eq!(restored.digest, definition.digest); +} + +#[test] +fn template_selection_refuses_missing_versions_and_wrong_contract_products() { + let root = tempfile::tempdir().unwrap(); + let mut project = workflow("messaging.submit-message/v1"); + project["contracts"] = json!({"notices": {"type": "messaging", "source": "contract.json"}}); + for (id, version) in [("missing", "1"), ("licence-issued", "2")] { + project["operations"]["request"]["template"] = + json!({"contract": "notices", "id": id, "version": version}); + write_project( + root.path(), + &project, + &template_contract(), + json!({}), + json!({}), + ); + let (exit, report) = cli(root.path(), "check"); + assert_eq!(exit, 1, "{report}"); + assert_eq!( + report["diagnostics"][0]["code"], + "coordinator.operation.contract-invalid" + ); + assert_eq!( + report["diagnostics"][0]["path"], + "/operations/request/template" + ); + } + project["operations"]["request"]["template"]["version"] = json!("1"); + write_project( + root.path(), + &project, + &record_schema(), + json!({}), + json!({}), + ); + let (exit, report) = cli(root.path(), "check"); + assert_eq!(exit, 1, "{report}"); +} + +#[test] +fn unbound_operations_retain_their_open_contracts() { + for (uses, input, reply) in [ + ( + "breg.read-record/v1", + json!({"collection": "licences", "recordId": "r-1"}), + record(json!({"licence-number": "L1"})), + ), + ( + "messaging.submit-message/v1", + json!({"senderProfile": "transactional", "to": {"email": "ada@example.invalid"}, "content": {"text": "hello"}}), + receipt(), + ), + ] { + let root = tempfile::tempdir().unwrap(); + write_project(root.path(), &workflow(uses), &json!({}), input, reply); + let (exit, report) = cli(root.path(), "test"); + assert_eq!(exit, 0, "{report}"); + } +} + +#[test] +fn explain_includes_authored_event_triggers_in_json_and_human_output() { + let root = tempfile::tempdir().unwrap(); + let mut project = workflow("breg.read-record/v1"); + let trigger = json!({"source": "licensing", "eventType": "licence-created-v1", + "dataSchema": "urn:example:licence-created:v1", "event": {"type": "object"}, + "input": {"type": "input"}}); + project["workflow"]["eventTriggers"] = json!({"licence-created": trigger}); + write_project( + root.path(), + &project, + &json!({}), + json!({"collection": "licences", "recordId": "r-1"}), + record(json!({})), + ); + let (exit, report) = cli(root.path(), "explain"); + assert_eq!(exit, 0, "{report}"); + assert_eq!( + report["eventTriggers"]["licence-created"], + json!({ + "source": trigger["source"], "eventType": trigger["eventType"], "dataSchema": trigger["dataSchema"], + "event": trigger["event"], "input": trigger["input"]}) + ); + let output = Command::new(env!("CARGO_BIN_EXE_coordinatorctl")) + .args(["explain", "--project"]) + .arg(root.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + assert!( + text.contains("licence-created: licensing licence-created-v1"), + "{text}" + ); + project["workflow"] + .as_object_mut() + .unwrap() + .remove("eventTriggers"); + fs::write(root.path().join("workflow.yaml"), project.to_string()).unwrap(); + let (exit, report) = cli(root.path(), "explain"); + assert_eq!(exit, 0, "{report}"); + assert_eq!(report["eventTriggers"], json!({})); +} diff --git a/crates/registry-coordinator/tests/read_lease_recovery.rs b/crates/registry-coordinator/tests/read_lease_recovery.rs new file mode 100644 index 0000000000..4f24f696ff --- /dev/null +++ b/crates/registry-coordinator/tests/read_lease_recovery.rs @@ -0,0 +1,472 @@ +// SPDX-License-Identifier: Apache-2.0 +//! A lapsed read lease retries, while any step that may have caused an effect holds. +#![cfg(feature = "postgres-test")] + +use async_trait::async_trait; +use registry_coordinator::{ + definition::Definition, + protected_state::StateKeys, + protocol::{AdapterSet, CallOutcome, CallRequest, Operation}, + store::{Store, StoreSecurity}, + worker::Worker, +}; +use registry_platform_audit::{AuditProfile, AuditWriter}; +use serde_json::{json, Value}; +use std::{ + collections::BTreeMap, + io::Write, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; +use tokio::sync::{Notify, Semaphore}; +use tokio_postgres::{Client, NoTls}; +use uuid::Uuid; + +const READ_WORKFLOW: &str = r#" +apiVersion: id.registrystack.org/formats/coordinator/project/v1alpha1 +kind: CoordinatorProject +project: {id: read-lease-recovery, version: v1} +operations: + read: + uses: breg.read-record/v1 + connection: source +workflow: + input: {type: object, additionalProperties: false} + deadlineSeconds: 3600 + start: read + steps: + read: + type: call + operation: read + input: + type: fields + fields: + collection: {type: literal, value: people} + recordId: {type: literal, value: 00000000-0000-4000-8000-000000000001} + next: done + done: + type: finish + outcome: observed + output: {type: step, step: read} + outcomes: + observed: {operationOutput: read} +"#; + +const MUTATION_WORKFLOW: &str = r#" +apiVersion: id.registrystack.org/formats/coordinator/project/v1alpha1 +kind: CoordinatorProject +project: {id: mutation-lease-recovery, version: v1} +operations: + send: + uses: messaging.submit-message/v1 + connection: notices +workflow: + input: {type: object, additionalProperties: false} + deadlineSeconds: 3600 + start: send + steps: + send: + type: call + operation: send + input: + type: literal + value: + senderProfile: reminders + to: {email: synthetic@example.invalid} + content: {text: Synthetic recovery proof} + next: done + done: {type: finish, outcome: sent} + outcomes: + sent: {type: 'null'} +"#; + +#[derive(Clone, Default)] +struct Capture(Arc>>); + +impl Write for Capture { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .expect("capture lock") + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Capture { + fn clear(&self) { + self.0.lock().expect("capture lock").clear(); + } + + fn entries(&self) -> Vec { + let bytes = self.0.lock().expect("capture lock"); + std::str::from_utf8(&bytes) + .expect("UTF-8 audit") + .lines() + .map(|line| serde_json::from_str(line).expect("JSON audit entry")) + .collect() + } +} + +struct Harness { + url: String, + namespace: String, + store: Arc, + definition: Definition, + capture: Capture, +} + +impl Harness { + async fn new(workflow: &str) -> Self { + let url = std::env::var("COORDINATOR_TEST_DATABASE_URL").expect( + "COORDINATOR_TEST_DATABASE_URL must name a disposable synthetic PostgreSQL 17+ database", + ); + let namespace = format!("coordinator_{}", Uuid::new_v4().simple()); + let bootstrap = Store::connect(&url, &namespace) + .await + .expect("local disposable connection"); + bootstrap.migrate().await.expect("isolated migration"); + let capture = Capture::default(); + let store = Arc::new( + Store::open( + &url, + &namespace, + StoreSecurity { + database_id: namespace.clone(), + keys: StateKeys::new(1, BTreeMap::from([(1, [0x11; 32])]), [0x22; 32]) + .expect("test keys"), + audit: AuditWriter::from_line_sink(Box::new(capture.clone())), + audit_profile: AuditProfile::production_from_secret_bytes( + zeroize::Zeroizing::new(vec![0x33; 32]), + ) + .expect("audit profile"), + }, + None, + ) + .await + .expect("open migrated store"), + ); + let project = tempfile::tempdir().expect("project"); + std::fs::write(project.path().join("workflow.yaml"), workflow).expect("workflow"); + let definition = Definition::load(project.path()).expect("valid recovery workflow"); + Self { + url, + namespace, + store, + definition, + capture, + } + } + + async fn admit(&self) -> Uuid { + let run = self + .store + .admit( + &self.definition, + json!({}), + "synthetic-producer", + &Uuid::new_v4().to_string(), + "binding-a", + ) + .await + .expect("admit"); + self.capture.clear(); + run + } + + async fn client(&self) -> Client { + let (client, connection) = tokio_postgres::connect(&self.url, NoTls) + .await + .expect("test connection"); + tokio::spawn(async move { + connection.await.expect("test connection finishes"); + }); + client + } + + async fn lapse(&self, run: Uuid) { + self.client() + .await + .execute( + &format!( + "UPDATE {}.jobs SET attempt_started_at=clock_timestamp()-interval '60 seconds',lease_expires_at=clock_timestamp()-interval '1 second' WHERE run_id=$1 AND state='leased'", + self.namespace + ), + &[&run], + ) + .await + .expect("lapse lease"); + } + + async fn make_retry_due(&self, run: Uuid) { + self.client() + .await + .execute( + &format!( + "UPDATE {}.jobs SET next_attempt_at=clock_timestamp()-interval '1 second' WHERE run_id=$1 AND state='pending'", + self.namespace + ), + &[&run], + ) + .await + .expect("make retry due"); + } + + async fn spend_attempts(&self, run: Uuid) { + self.client() + .await + .execute( + &format!( + "UPDATE {}.jobs SET attempt=10 WHERE run_id=$1 AND state='leased'", + self.namespace + ), + &[&run], + ) + .await + .expect("spend attempt budget"); + } + + async fn expire_run(&self, run: Uuid) { + self.client() + .await + .execute( + &format!( + "UPDATE {}.runs SET deadline_at=clock_timestamp()-interval '1 second' WHERE run_id=$1", + self.namespace + ), + &[&run], + ) + .await + .expect("expire run"); + } + + async fn job(&self, run: Uuid, step: &str) -> (String, i16, i64) { + let row = self + .client() + .await + .query_one( + &format!( + "SELECT state,attempt,generation FROM {}.jobs WHERE run_id=$1 AND step=$2", + self.namespace + ), + &[&run, &step], + ) + .await + .expect("job row"); + (row.get(0), row.get(1), row.get(2)) + } + + fn assert_lapse_audit(&self) { + let entries: Vec<_> = self + .capture + .entries() + .into_iter() + .filter(|entry| entry["record"]["action"] == "lease-lapsed") + .collect(); + assert_eq!(entries.len(), 2, "one request/response lapse audit pair"); + assert_eq!(entries[0]["phase"], "request"); + assert_eq!(entries[0]["record"]["outcome"], "intent"); + assert_eq!(entries[1]["phase"], "response"); + assert_eq!(entries[1]["record"]["outcome"], "committed"); + assert_eq!(entries[0]["correlation"], entries[1]["correlation"]); + } +} + +struct BlockingAdapter { + calls: AtomicUsize, + entered: Notify, + release: Semaphore, + marker: &'static str, +} + +impl BlockingAdapter { + fn new(marker: &'static str) -> Arc { + Arc::new(Self { + calls: AtomicUsize::new(0), + entered: Notify::new(), + release: Semaphore::new(0), + marker, + }) + } +} + +struct ImmediateAdapter { + calls: AtomicUsize, + marker: &'static str, +} + +impl ImmediateAdapter { + fn new(marker: &'static str) -> Arc { + Arc::new(Self { + calls: AtomicUsize::new(0), + marker, + }) + } +} + +fn response(operation: Operation, marker: &str) -> CallOutcome { + if operation == Operation::ReadRecord { + CallOutcome::Success(json!({ + "meta": { + "registryIdentifier": "fixture", + "datasetIdentifier": "people", + "entityTypeIdentifier": "person" + }, + "data": { + "recordIdentifier": "00000000-0000-4000-8000-000000000001", + "revisionIdentifier": marker, + "domainData": {} + } + })) + } else { + CallOutcome::Success(json!({ + "id": "00000000-0000-4000-8000-000000000002", + "status": "queued", + "links": { + "self": "/v1/messages/00000000-0000-4000-8000-000000000002", + "cancel": "/v1/messages/00000000-0000-4000-8000-000000000002/cancel" + } + })) + } +} + +#[async_trait] +impl AdapterSet for BlockingAdapter { + fn binding_digest(&self) -> &str { + "binding-a" + } + + async fn call(&self, request: &CallRequest) -> CallOutcome { + self.calls.fetch_add(1, Ordering::SeqCst); + self.entered.notify_one(); + let permit = self.release.acquire().await.expect("release stalled call"); + permit.forget(); + response(request.operation, self.marker) + } +} + +#[async_trait] +impl AdapterSet for ImmediateAdapter { + fn binding_digest(&self) -> &str { + "binding-a" + } + + async fn call(&self, request: &CallRequest) -> CallOutcome { + self.calls.fetch_add(1, Ordering::SeqCst); + response(request.operation, self.marker) + } +} + +async fn stall_attempt( + harness: &Harness, + adapter: Arc, +) -> tokio::task::JoinHandle> { + let worker = Worker::new(harness.store.clone(), adapter.clone()); + let stalled = tokio::spawn(async move { worker.tick().await }); + tokio::time::timeout(Duration::from_secs(10), adapter.entered.notified()) + .await + .expect("worker reaches adapter"); + stalled +} + +#[tokio::test] +async fn lapsed_read_retries_and_fences_the_old_attempt() { + let harness = Harness::new(READ_WORKFLOW).await; + let run = harness.admit().await; + let stale = BlockingAdapter::new("stale"); + let stalled = stall_attempt(&harness, stale.clone()).await; + harness.lapse(run).await; + + let replacement = ImmediateAdapter::new("replacement"); + let worker = Worker::new(harness.store.clone(), replacement.clone()); + worker.tick().await.expect("recover lapsed read"); + assert_eq!(harness.job(run, "read").await.0, "pending"); + let retrying = harness.store.status(run).await.expect("retrying status"); + assert_eq!(retrying.state, "running"); + assert!(retrying.next_due_at.is_some()); + harness.assert_lapse_audit(); + + harness.make_retry_due(run).await; + assert!(worker.tick().await.expect("replacement read")); + assert_eq!(replacement.calls.load(Ordering::SeqCst), 1); + let after_replacement = harness.job(run, "read").await; + assert_eq!(after_replacement.0, "delivered"); + assert_eq!(after_replacement.1, 2); + + stale.release.add_permits(1); + assert!(stalled.await.expect("stalled worker task").is_err()); + assert_eq!(harness.job(run, "read").await, after_replacement); + + assert!(worker.tick().await.expect("finish replacement read")); + let status = harness.store.status(run).await.expect("finished status"); + assert_eq!(status.state, "finished"); + assert_eq!( + status.output.unwrap()["data"]["revisionIdentifier"], + "replacement" + ); + assert_eq!(stale.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn lapsed_mutation_holds_unknown_for_operator_review() { + let harness = Harness::new(MUTATION_WORKFLOW).await; + let run = harness.admit().await; + let stale = BlockingAdapter::new("stale"); + let stalled = stall_attempt(&harness, stale.clone()).await; + harness.lapse(run).await; + + let worker = Worker::new(harness.store.clone(), ImmediateAdapter::new("replacement")); + worker.tick().await.expect("recover lapsed mutation"); + assert_eq!(harness.job(run, "send").await.0, "unknown"); + let status = harness.store.status(run).await.expect("held status"); + assert_eq!(status.state, "attention"); + assert_eq!(status.failure_code.as_deref(), Some("lease-lapsed")); + harness.assert_lapse_audit(); + + stale.release.add_permits(1); + assert!(stalled.await.expect("stalled worker task").is_err()); + assert_eq!(harness.job(run, "send").await.0, "unknown"); +} + +#[tokio::test] +async fn lapsed_read_holds_when_attempts_are_spent() { + let harness = Harness::new(READ_WORKFLOW).await; + let run = harness.admit().await; + let stale = BlockingAdapter::new("stale"); + let stalled = stall_attempt(&harness, stale.clone()).await; + harness.spend_attempts(run).await; + harness.lapse(run).await; + + let worker = Worker::new(harness.store.clone(), ImmediateAdapter::new("replacement")); + worker.tick().await.expect("recover exhausted read"); + assert_eq!(harness.job(run, "read").await.0, "unknown"); + assert_eq!(harness.store.status(run).await.unwrap().state, "attention"); + + stale.release.add_permits(1); + assert!(stalled.await.expect("stalled worker task").is_err()); +} + +#[tokio::test] +async fn lapsed_read_holds_after_its_deadline() { + let harness = Harness::new(READ_WORKFLOW).await; + let run = harness.admit().await; + let stale = BlockingAdapter::new("stale"); + let stalled = stall_attempt(&harness, stale.clone()).await; + harness.expire_run(run).await; + harness.lapse(run).await; + + let worker = Worker::new(harness.store.clone(), ImmediateAdapter::new("replacement")); + worker.tick().await.expect("recover expired read"); + assert_eq!(harness.job(run, "read").await.0, "unknown"); + assert_eq!(harness.store.status(run).await.unwrap().state, "attention"); + + stale.release.add_permits(1); + assert!(stalled.await.expect("stalled worker task").is_err()); +} diff --git a/crates/registry-messagingctl/src/lib.rs b/crates/registry-messagingctl/src/lib.rs index 23355148ba..14582d3f99 100644 --- a/crates/registry-messagingctl/src/lib.rs +++ b/crates/registry-messagingctl/src/lib.rs @@ -4,6 +4,8 @@ //! tooling. //! //! - `init` writes a starter authoring project and runtime example. +//! - `generate templates` writes the checked template versions, locales, and +//! data schemas as one deterministic contract document for another tool. //! - `check` loads a package, or a runtime configuration and the package it //! names, exactly as `messaging serve` would, offline: no secret is //! resolved, no database is reached, and no signing key is fetched. It @@ -80,6 +82,7 @@ mod dev; mod report; mod selected_binary; mod starter; +mod template_contracts; use report::{DOMAIN_REFUSAL_EXIT, OPERATIONAL_FAILURE_EXIT, USAGE_EXIT}; @@ -106,6 +109,9 @@ enum Command { Init(InitArgs), /// Build an immutable runtime package from an editable project. Package(PackageArgs), + /// Generate bounded contracts for another tool from an editable project. + #[command(subcommand)] + Generate(GenerateCommand), /// Check an authoring project, installed package, or runtime file. Check(CheckArgs), /// Render one template version with the given data, offline. @@ -142,6 +148,22 @@ enum Command { }, } +#[derive(Debug, Subcommand)] +enum GenerateCommand { + /// Write every checked template version's locale and data contract. + Templates(GenerateTemplatesArgs), +} + +#[derive(Debug, Args)] +struct GenerateTemplatesArgs { + /// Editable Messaging project holding messaging.yaml. + #[arg(long, value_name = "DIRECTORY")] + project: PathBuf, + /// New JSON file to create. Existing files are never overwritten. + #[arg(long, value_name = "FILE")] + output: PathBuf, +} + #[derive(Debug, Args)] struct ServeArgs { /// Runtime configuration file passed unchanged to the Messaging runtime. @@ -424,6 +446,7 @@ struct Outcome { enum View { Init, Package, + GenerateTemplates, Check, /// A refusal whose diagnostics carry their positions, printed as the /// shared human report (CFG-DIAG-2). @@ -531,6 +554,10 @@ fn guidance(code: &str, exit: u8) -> (&'static str, &'static str) { "runtime-dependency", "Retry the command; if it fails again, report the message.", ), + "output.exists" => ( + "filesystem", + "Name a new --output file; an existing file is never overwritten.", + ), "package.outcome-unknown" => ( "database", "Run messagingctl status to see which activation the ledger records before applying again.", @@ -693,6 +720,7 @@ where } Command::Init(args) => init(&args.directory), Command::Package(args) => package(&args), + Command::Generate(GenerateCommand::Templates(args)) => generate_templates(&args), Command::Check(args) => check(&args), Command::Preview(args) => preview(&args), Command::Plan(args) => plan(&args.runtime_config), @@ -710,6 +738,7 @@ fn command_path(command: &Command) -> &'static str { Command::Serve(_) => "serve", Command::Init(_) => "init", Command::Package(_) => "package", + Command::Generate(GenerateCommand::Templates(_)) => "generate templates", Command::Check(_) => "check", Command::Preview(_) => "preview", Command::Plan(_) => "plan", @@ -906,6 +935,39 @@ fn package(args: &PackageArgs) -> Outcome { ) } +fn generate_templates(args: &GenerateTemplatesArgs) -> Outcome { + match template_contracts::write(&args.project, &args.output) { + Ok(templates) => Outcome::new( + json!({ + "ok": true, + "project": args.project, + "output": args.output, + "templates": templates, + }), + View::GenerateTemplates, + ), + Err(template_contracts::ExportError::Package(error)) => { + package_refusal(&args.project, &error) + } + Err(template_contracts::ExportError::Write(error)) + if error.kind() == io::ErrorKind::AlreadyExists => + { + Outcome::refused( + DOMAIN_REFUSAL_EXIT, + "output.exists", + "--output", + "the output file already exists; generate never overwrites".to_owned(), + ) + } + Err(error) => Outcome::refused( + OPERATIONAL_FAILURE_EXIT, + "output.failed", + "--output", + error.to_string(), + ), + } +} + fn init(directory: &Path) -> Outcome { match starter::write(directory) { Ok(created) => Outcome::new( @@ -2009,6 +2071,7 @@ fn render_human( match outcome.view { View::Init => render_init(report, stdout), View::Package => render_package(report, stdout), + View::GenerateTemplates => render_generate_templates(report, stdout), View::Check | View::Diagnostics => { render_check(report, stdout)?; if report.get("filesChecked").is_some() && !warned { @@ -2049,6 +2112,11 @@ fn render_package(report: &Value, stdout: &mut dyn io::Write) -> io::Result<()> writeln!(stdout, "package files: {}", report["projectFiles"]) } +fn render_generate_templates(report: &Value, stdout: &mut dyn io::Write) -> io::Result<()> { + writeln!(stdout, "created: {}", text(&report["output"]))?; + writeln!(stdout, "template versions: {}", report["templates"]) +} + fn render_dev(report: &Value, stdout: &mut dyn io::Write) -> io::Result<()> { if report["state"] == json!("stopped") { writeln!(stdout, "stopped; the session's containers are removed")?; diff --git a/crates/registry-messagingctl/src/template_contracts.rs b/crates/registry-messagingctl/src/template_contracts.rs new file mode 100644 index 0000000000..84ffb8ee37 --- /dev/null +++ b/crates/registry-messagingctl/src/template_contracts.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Deterministic public contracts for authored template versions. + +use std::fmt; +use std::fs::OpenOptions; +use std::io::{self, Write as _}; +use std::path::Path; + +use registry_messaging::package::{package_inputs, PackageLoadError, SCHEMA_FILE, TEMPLATE_FILE}; +use registry_messaging_core::TemplateDocument; +use registry_platform_config::AuthoredExpressions; +use registry_platform_yaml::Reader; +use serde::Serialize; +use serde_json::Value; + +const API_VERSION: &str = "id.registrystack.org/formats/messaging/template-contracts/v1"; +const KIND: &str = "MessagingTemplateContracts"; + +#[derive(Debug)] +pub(crate) enum ExportError { + Package(PackageLoadError), + Snapshot, + Encode(serde_json::Error), + Write(io::Error), +} + +impl From for ExportError { + fn from(error: PackageLoadError) -> Self { + Self::Package(error) + } +} + +impl fmt::Display for ExportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Package(error) => error.fmt(formatter), + Self::Snapshot => formatter + .write_str("the checked project snapshot could not produce template contracts"), + Self::Encode(error) => { + write!( + formatter, + "the template contracts could not be encoded: {error}" + ) + } + Self::Write(error) => write!( + formatter, + "the output file could not be created or written: {error}" + ), + } + } +} + +impl std::error::Error for ExportError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Package(error) => Some(error), + Self::Encode(error) => Some(error), + Self::Write(error) => Some(error), + Self::Snapshot => None, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct TemplateContracts { + api_version: &'static str, + kind: &'static str, + templates: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct TemplateContract { + id: String, + version: String, + locales: Vec, + data_schema: Value, +} + +/// Validate `project`, then write one create-only export outside its package +/// inputs. The returned count is the number of template versions exported. +pub(crate) fn write(project: &Path, output: &Path) -> Result { + let inputs = package_inputs(project)?; + let mut templates = Vec::new(); + for (schema_path, schema_bytes) in &inputs { + let Some((id, version)) = template_schema_reference(schema_path) else { + continue; + }; + let template_path = format!("templates/{id}/{version}/{TEMPLATE_FILE}"); + let template_bytes = inputs.get(&template_path).ok_or(ExportError::Snapshot)?; + let mut hook = AuthoredExpressions; + let document = TemplateDocument::decode( + Reader::new(project.join(&template_path).display().to_string()).with_hook(&mut hook), + template_bytes, + ) + .map_err(|_| ExportError::Snapshot)?; + let data_schema = serde_json::from_slice(schema_bytes).map_err(ExportError::Encode)?; + let mut locales = document.value.locales; + locales.sort(); + templates.push(TemplateContract { + id: id.to_owned(), + version: version.to_owned(), + locales, + data_schema, + }); + } + templates.sort_by(|left, right| (&left.id, &left.version).cmp(&(&right.id, &right.version))); + let count = templates.len(); + let mut bytes = serde_json::to_vec(&TemplateContracts { + api_version: API_VERSION, + kind: KIND, + templates, + }) + .map_err(ExportError::Encode)?; + bytes.push(b'\n'); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(output) + .map_err(ExportError::Write)?; + file.write_all(&bytes).map_err(ExportError::Write)?; + Ok(count) +} + +fn template_schema_reference(path: &str) -> Option<(&str, &str)> { + let mut parts = path.split('/'); + match ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ) { + (Some("templates"), Some(id), Some(version), Some(file), None) if file == SCHEMA_FILE => { + Some((id, version)) + } + _ => None, + } +} diff --git a/crates/registry-messagingctl/tests/template_contracts.rs b/crates/registry-messagingctl/tests/template_contracts.rs new file mode 100644 index 0000000000..bcbd636299 --- /dev/null +++ b/crates/registry-messagingctl/tests/template_contracts.rs @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Public CLI contract for deterministic Messaging template exports. + +use std::path::Path; +use std::process::{Command, Output}; + +use serde_json::{json, Value}; + +fn messagingctl(arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_messagingctl")) + .args(arguments) + .output() + .expect("messagingctl runs") +} + +fn success(arguments: &[&str]) -> Output { + let output = messagingctl(arguments); + assert!( + output.status.success(), + "messagingctl {arguments:?} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + output +} + +fn json_stdout(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).expect("stdout is JSON") +} + +fn path(path: &Path) -> &str { + path.to_str().expect("temporary path is UTF-8") +} + +#[test] +fn generate_templates_exports_deterministic_checked_contracts_without_changing_the_package() { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + success(&["init", path(&project)]); + + let before = json_stdout(&success(&[ + "--format=json", + "package", + path(&project), + "--output", + path(&temporary.path().join("unused-before")), + "--dry-run", + ])); + + let first = project.join("template-contracts.json"); + let first_report = json_stdout(&success(&[ + "--format=json", + "generate", + "templates", + "--project", + path(&project), + "--output", + path(&first), + ])); + assert_eq!(first_report["command"], "generate templates"); + assert_eq!(first_report["output"], path(&first)); + assert_eq!(first_report["templates"], 2); + + let second = temporary.path().join("template-contracts-second.json"); + success(&[ + "generate", + "templates", + "--project", + path(&project), + "--output", + path(&second), + ]); + + let first_bytes = std::fs::read(&first).unwrap(); + let second_bytes = std::fs::read(&second).unwrap(); + assert_eq!(first_bytes, second_bytes, "the export is byte-stable"); + assert_eq!(first_bytes.last(), Some(&b'\n')); + + let export: Value = serde_json::from_slice(&first_bytes).unwrap(); + assert_eq!( + export, + json!({ + "apiVersion": "id.registrystack.org/formats/messaging/template-contracts/v1", + "kind": "MessagingTemplateContracts", + "templates": [ + { + "id": "appointment-reminder", + "version": "1", + "locales": ["en", "fr"], + "dataSchema": serde_json::from_slice::( + &std::fs::read(project.join( + "templates/appointment-reminder/1/schema.json" + )).unwrap() + ).unwrap(), + }, + { + "id": "appointment-reminder-sms", + "version": "1", + "locales": ["en"], + "dataSchema": serde_json::from_slice::( + &std::fs::read(project.join( + "templates/appointment-reminder-sms/1/schema.json" + )).unwrap() + ).unwrap(), + }, + ], + }) + ); + + let after = json_stdout(&success(&[ + "--format=json", + "package", + path(&project), + "--output", + path(&temporary.path().join("unused-after")), + "--dry-run", + ])); + assert_eq!(before["projectDigest"], after["projectDigest"]); + + let installed = temporary.path().join("installed"); + success(&["package", path(&project), "--output", path(&installed)]); + assert!(installed.join("SHA256SUMS").is_file()); + assert!(!installed.join("template-contracts.json").exists()); +} + +#[test] +fn generate_templates_is_create_only() { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + success(&["init", path(&project)]); + let output = temporary.path().join("contracts.json"); + std::fs::write(&output, b"keep this").unwrap(); + + let refused = messagingctl(&[ + "--format=json", + "generate", + "templates", + "--project", + path(&project), + "--output", + path(&output), + ]); + assert_eq!(refused.status.code(), Some(1)); + let report = json_stdout(&refused); + assert_eq!(report["diagnostics"][0]["code"], "output.exists"); + assert_eq!(std::fs::read(&output).unwrap(), b"keep this"); +} diff --git a/products/breg/SECURITY-REVIEW-NOTES.md b/products/breg/SECURITY-REVIEW-NOTES.md index c56131697d..162b9f19f3 100644 --- a/products/breg/SECURITY-REVIEW-NOTES.md +++ b/products/breg/SECURITY-REVIEW-NOTES.md @@ -4690,3 +4690,46 @@ backend selection. `native_plan_security` retains the BReg resource, deadline, fresh-call recovery, closed-capability, and value-free error checks. Existing package and protected CI gates cover the change; no new blocking gate is introduced. + +## Action and Proposal requester clients match verifier admission + +### Threat + +An access profile with only Action or Proposal permissions could name a +`requesterClients` entry absent from `authentication.oidc.allowedClients`. +Offline checking and startup accepted that configuration, although token +verification refused every request by the named client before its grant could +be used. + +### Enforcement and defaults + +`validate_claim_mapping` in `crates/registry-breg/src/auth.rs` now checks the +requester clients copied into every compiled Action permission and every +compiled Proposal permission, alongside the existing Record, statistical +dataset, trusted-actor, and consent-recipient checks. An absent client returns +`NamedClientNotListed`, which `bregctl check --runtime-config` reports as +`breg.runtime.clients-unlisted` without rendering the client identifier. + +This change compares only the compiled requester clients with verifier +admission. It does not copy the Record and statistical-profile trusted-actor +restriction onto Action or Proposal permissions. A standing agent may still +start or edit its caller-owned draft under the existing operation ceiling. + +### Tests + +- `crates/registry-breg/tests/http_auth.rs`: + `action_and_proposal_only_clients_are_checked_at_construction` refuses an + unlisted Action-only client and an unlisted Proposal-only client, accepts + both when listed, and accepts the Proposal-only standing agent without a + trusted-actor mapping. +- `crates/registry-bregctl/tests/cli.rs`: + `check_refuses_unlisted_clients_on_action_and_proposal_only_profiles` proves + the offline runtime-config refusal and its listed-client control on the + maintained minimal project with Record permissions removed. +- `crates/registry-breg/src/api/tests/immediate_action_tests.rs`: + `proposal_task_scopes_are_exact_and_standing_agents_only_start_or_edit_owned_drafts` + retains the existing standing-agent operation ceiling. + +No request authorization or PostgreSQL path changes. The construction check +fails earlier on a configuration whose named client could never pass token +verification. diff --git a/products/breg/contracts/security-invariant-matrix.yaml b/products/breg/contracts/security-invariant-matrix.yaml index c10d4e2f56..3d3b780ae9 100644 --- a/products/breg/contracts/security-invariant-matrix.yaml +++ b/products/breg/contracts/security-invariant-matrix.yaml @@ -99,7 +99,9 @@ invariants: - {id: BREG-SEC-96, state: enforced, targetWave: W5, threat: "A read path from an ungated root reaches a gated intermediate or target entity without its consent check.", enforcementPoint: "compiler read-path consent validation", refusal: "Reject a read path whose intermediate or target entity is gated with breg.consent.require-read-path-target.", negativeId: BREG-NEG-96, negativeTest: {path: crates/registry-breg/tests/consent_access.rs, name: read_paths_cannot_reach_a_gated_entity}} - {id: BREG-SEC-97, state: enforced, targetWave: W5, threat: "A recipient learns through its consent feed that a subject refused it.", enforcementPoint: "compiler-added decision row boundary on the feed, filled by the engine with gives and non-refusal revokes", refusal: "Omit refusals from every recipient feed read.", negativeId: BREG-NEG-97, negativeTest: {path: crates/registry-breg/tests/postgres_consent_examples.rs, name: person_registry_consent_journeys_run_through_the_authenticated_postgres_runner}} - {id: BREG-SEC-98, state: enforced, targetWave: W5, threat: "The reserved recipient or feed decision claim authorizes a principal, a non-feed row boundary, an entity access requirement, a lookup claim mapping, an apply target, or a request presence grant.", enforcementPoint: "compiler reserved-claim validation", refusal: "Reject any use of registry:recipients other than an in boundary on a consent record's recipient field in a get and list permission, and any authored registry:consent-decisions claim, on every surface that names a verified claim, with breg.consent.feed-claim.", negativeId: BREG-NEG-98, negativeTest: {path: crates/registry-breg/tests/consent_access.rs, name: the_recipient_claim_binds_only_the_consent_feed}} - - {id: BREG-SEC-99, state: enforced, targetWave: W5, threat: "A recipient client is one the configured token verifier would reject, so the recipient mapping and client admission disagree.", enforcementPoint: "authenticator construction over the recipients configuration and the issuer allowedClients", refusal: "Refuse startup when a recipient organization lists a client absent from allowedClients.", negativeId: BREG-NEG-99, negativeTest: {path: crates/registry-breg/tests/consent_access.rs, name: every_recipient_client_must_be_an_allowed_client}} + - {id: BREG-SEC-99, state: enforced, targetWave: W5, threat: "A client named by requesterClients or a consent recipient is one the configured token verifier would reject, so authored authority and client admission disagree.", enforcementPoint: "authenticator construction over compiled Record, Action, Proposal, statistical-dataset, and recipient client bindings and the issuer allowedClients", refusal: "Refuse startup when any compiled authority or recipient binding names a client absent from allowedClients.", negativeId: BREG-NEG-99, negativeTest: {path: crates/registry-breg/tests/consent_access.rs, name: every_recipient_client_must_be_an_allowed_client}} + # Action and Proposal grant proof: crates/registry-breg/tests/http_auth.rs::action_and_proposal_only_clients_are_checked_at_construction + # Offline runtime-config proof: crates/registry-bregctl/tests/cli.rs::check_refuses_unlisted_clients_on_action_and_proposal_only_profiles - {id: BREG-SEC-100, state: enforced, targetWave: W5, threat: "A successor package leaves a stale consent probe or index that a fresh install of the same project would not have.", enforcementPoint: "successor migration planning for consent probes and indexes", refusal: "Replace or drop every consent probe and index so an upgraded catalog matches a fresh install when a consent record is added, changed, or removed.", negativeId: BREG-NEG-100, negativeTest: {path: crates/registry-breg/tests/postgres_consent_access.rs, name: real_postgres_consent_successors_match_fresh_install_when_added_changed_and_removed}} - {id: BREG-SEC-101, state: enforced, targetWave: W5, threat: "A self consent action discloses whether a subject exists to a caller whose link does not cover it.", enforcementPoint: "the self action's link requirement binding the link subject to the subject input, checked as one precondition whether or not the subject exists", refusal: "Answer an unknown subject with the same precondition failure and body as a subject the caller's link does not cover.", negativeId: BREG-NEG-101, negativeTest: {path: crates/registry-breg/tests/postgres_consent_examples.rs, name: self_action_answers_an_unknown_subject_like_a_subject_its_link_does_not_cover}} - {id: BREG-SEC-102, state: enforced, targetWave: W5, threat: "An ingestion run is opened while the audit writer is unavailable, leaving a run with no accountable request.", enforcementPoint: "create_ingestion_run appends a correlated ingestion request entry before its first run read or write", refusal: "Refuse the creation as unavailable and open no run when the audit writer does not accept the request entry.", negativeId: BREG-NEG-102, negativeTest: {path: crates/registry-breg/tests/postgres_ingestion_runs.rs, name: run_creation_opens_no_run_when_the_audit_writer_refuses_its_request_entry}} diff --git a/products/breg/contracts/security-test-traceability.yaml b/products/breg/contracts/security-test-traceability.yaml index 0f2f1ce034..e6a91b3ec9 100644 --- a/products/breg/contracts/security-test-traceability.yaml +++ b/products/breg/contracts/security-test-traceability.yaml @@ -99,6 +99,8 @@ traceability: - {id: BREG-SEC-97, state: enforced, negativeId: BREG-NEG-97, negativeTest: {path: crates/registry-breg/tests/postgres_consent_examples.rs, name: person_registry_consent_journeys_run_through_the_authenticated_postgres_runner}} - {id: BREG-SEC-98, state: enforced, negativeId: BREG-NEG-98, negativeTest: {path: crates/registry-breg/tests/consent_access.rs, name: the_recipient_claim_binds_only_the_consent_feed}} - {id: BREG-SEC-99, state: enforced, negativeId: BREG-NEG-99, negativeTest: {path: crates/registry-breg/tests/consent_access.rs, name: every_recipient_client_must_be_an_allowed_client}} + # Action and Proposal grant proof: crates/registry-breg/tests/http_auth.rs::action_and_proposal_only_clients_are_checked_at_construction + # Offline runtime-config proof: crates/registry-bregctl/tests/cli.rs::check_refuses_unlisted_clients_on_action_and_proposal_only_profiles - {id: BREG-SEC-100, state: enforced, negativeId: BREG-NEG-100, negativeTest: {path: crates/registry-breg/tests/postgres_consent_access.rs, name: real_postgres_consent_successors_match_fresh_install_when_added_changed_and_removed}} - {id: BREG-SEC-101, state: enforced, negativeId: BREG-NEG-101, negativeTest: {path: crates/registry-breg/tests/postgres_consent_examples.rs, name: self_action_answers_an_unknown_subject_like_a_subject_its_link_does_not_cover}} - {id: BREG-SEC-102, state: enforced, negativeId: BREG-NEG-102, negativeTest: {path: crates/registry-breg/tests/postgres_ingestion_runs.rs, name: run_creation_opens_no_run_when_the_audit_writer_refuses_its_request_entry}} diff --git a/products/coordinator/DEPLOYMENT.md b/products/coordinator/DEPLOYMENT.md index 8d8ea74825..9959a8f832 100644 --- a/products/coordinator/DEPLOYMENT.md +++ b/products/coordinator/DEPLOYMENT.md @@ -631,8 +631,11 @@ further attempt and finishes the one in flight. Both get 15 seconds, the service then logs `stopped` and exits 0. Give the supervisor a stop timeout of at least 15 seconds before it sends SIGKILL. An attempt still unfinished when the 15 seconds end, or when the process is killed, keeps its lease until the -lease expires; a worker then holds it for operator review, as it holds the -attempt of a worker that crashed. +lease expires. A `read-again` call then resumes on the ordinary retry schedule +within its original attempt and deadline bounds. Any other step, including a +mutation, evaluation or same-command call, holds for operator review. Both +transitions retain the lease-lapse audit record, and a late completion from the +old worker is fenced out. Apply serializes activation with database locks, migrates the dedicated schema, grants the runtime role, records the verified set of packages in the shared @@ -880,6 +883,20 @@ the call's failure code. After the tenth attempt the run reports `failed` with that code and waits for the recovery below. Uncertain answers are never retried automatically. +A worker lease that lapses has a narrower recovery rule based on the operation +in the run's protected definition snapshot: + +| Lapsed step | Result after the lease expires | +|---|---| +| A call whose recovery is `read-again` | The saved read becomes `pending` on the ordinary backoff and runs again without `retry-same`. If its attempt limit is spent or the retry cannot start before the original run deadline, it holds `unknown` instead of becoming dead-lettered or expired. | +| A mutation, evaluation, same-command call, wait or local step | The job holds `unknown` and the run reports `attention`; inspect it before an operator recovery action. | + +The transition is audited whether it reschedules or holds. Dispatch increments +the attempt and changes the lease token before a replacement read starts, so a +late result from the old worker cannot commit over it. This lease recovery is +not a new effect-retry rule: ambiguous mutations, evaluations and frozen +same-command effects remain held. + An `external.get/v1` reply with status 429, 502, 503 or 504 is a definite retryable answer on every attempt but the tenth: 429 retries as `external-rate-limited` and the others as `external-unavailable`. The tenth diff --git a/products/coordinator/README.md b/products/coordinator/README.md index f4544f12f0..3d665aac85 100644 --- a/products/coordinator/README.md +++ b/products/coordinator/README.md @@ -205,6 +205,52 @@ Standalone Casework review is independent of BReg. Its submission includes an ex subject, and submission digest. Casework owns reviewer authority and outcomes. Review completion creates no registry effect. A later BReg or Messaging call requires its own authority. +## Record and template contracts + +Import a BReg record schema when you want scenarios to reject field names the record type +does not serve. Import Messaging's template export when you want computed message data checked +against the template before deployment. Generate both from the owning projects, then copy the +selected artifacts into your Coordinator project: + +```sh +bregctl generate schemas ./source-registry --output ./record-contracts +messagingctl generate templates --project ./notices --output ./notice-contracts.json +``` + +Reference the copied files in `workflow.yaml`: + +```yaml +contracts: + application-record: + type: breg + source: generated/application.schema.json + notices: + type: messaging + source: generated/notice-contracts.json +operations: + read-application: + uses: breg.read-record/v1 + connection: applications + record: {contract: application-record} + send-notice: + uses: messaging.submit-message/v1 + connection: notices + template: {contract: notices, id: application-approved, version: '1'} +``` + +The record import limits `domainData` to the schema's API names. It allows omitted fields and +leaves value types open, because current BReg access policy may hide fields. The template import +fixes the template ID, version, locales and data schema; that alias accepts template submissions +only. `coordinatorctl test` rejects misspelled fake record members with `scenario.reply-output` +and invalid computed template data with `scenario.call-input`. Aliases without `record` or +`template` retain their general contracts. Regenerate imports when the owning project changes. + +Template data schemas use [Messaging's supported reference form](../messaging/README.md#running-locally). + +These imports grant no access. Messaging still checks the connection's caller, sender profile +and template at request time. The [earlier appointment alert](examples/earlier-appointment-alerts/README.md) +shows both imports alongside their owning projects. + ## External commands `external.command/v1` sends one POST to a provider the operator configured. Workflow data diff --git a/products/coordinator/SECURITY-REVIEW-NOTES.md b/products/coordinator/SECURITY-REVIEW-NOTES.md index 63eeb85086..a406aad802 100644 --- a/products/coordinator/SECURITY-REVIEW-NOTES.md +++ b/products/coordinator/SECURITY-REVIEW-NOTES.md @@ -5,6 +5,55 @@ material because commit messages do not survive a squash. Each section names the threat it answers, the defaults the product ships, where Rust enforces it, the tests that pin it, and the residual risk it accepts. +## Lapsed read leases + +### Threat + +A worker can exit after leasing a read but before recording its result. Holding +every such job delays a workflow even though repeating the operation is safe. +Conversely, treating every lapsed lease as retryable could repeat a mutation, +evaluation or same-command effect whose remote outcome is unknown. A late +completion from the original worker could also race the replacement attempt. + +### Enforcement and defaults + +`Store::decode_lapsed` in `crates/registry-coordinator/src/store.rs` opens the +authenticated definition snapshot for the selected run and resolves the exact +leased step. Only a call whose catalog recovery is `read-again` receives +Dispatch's `RetryThenHold` policy. Every mutation, evaluation, same-command +call, wait and local step retains `Hold`; no database flag, schema change or +mutable runtime catalog decision is involved. + +`RetryThenHold` uses the existing backoff, ten-attempt limit and original run +deadline. It schedules another read while those bounds allow it, but converts +the dead-letter or expiry edge to `unknown`, so repeated failure still reaches +operator review. Dispatch commits the `lease-lapsed` transition audit before +release and fences completion by generation, attempt and lease token. A stale +worker cannot commit after the replacement attempt starts. + +### Tests + +- `crates/registry-coordinator/tests/read_lease_recovery.rs`: + `lapsed_read_retries_and_fences_the_old_attempt`, + `lapsed_mutation_holds_unknown_for_operator_review`, + `lapsed_read_holds_when_attempts_are_spent`, and + `lapsed_read_holds_after_its_deadline` exercise the PostgreSQL transition, + audit pair, retry count, deadline and stale-worker fence. + +These are maintained references, not claims that the tests ran in this note. + +### Accepted residuals + +- A replacement read can observe newer source state than the interrupted read + would have returned. `read-again` promises a safe current observation, not a + historical response replay. +- A source incorrectly configured as a read can still have side effects. The + closed operation catalog, fixed HTTP method and reviewed package are the + boundary; Coordinator cannot prove a provider's implementation is read-only. +- A read that repeatedly loses its worker still stops at `attention` when its + existing attempt or deadline bound is spent. Recovery does not extend either + budget. + ## Scheduling lifecycle recovery ### Threat diff --git a/products/coordinator/examples/earlier-appointment-alerts/README.md b/products/coordinator/examples/earlier-appointment-alerts/README.md index a7670871b6..839494b2d5 100644 --- a/products/coordinator/examples/earlier-appointment-alerts/README.md +++ b/products/coordinator/examples/earlier-appointment-alerts/README.md @@ -49,14 +49,26 @@ bregctl check \ bregctl generate actions \ products/coordinator/examples/earlier-appointment-alerts/source-registry \ --output /new/scratch/path +bregctl generate schemas \ + products/coordinator/examples/earlier-appointment-alerts/source-registry \ + --output /new/record-contracts +messagingctl generate templates \ + --project products/coordinator/examples/earlier-appointment-alerts/notices \ + --output /new/notices.contract.json coordinatorctl check \ --project products/coordinator/examples/earlier-appointment-alerts --explain coordinatorctl test \ --project products/coordinator/examples/earlier-appointment-alerts ``` -The committed `generated/alerts.contract.json` was generated from the adjacent -source project. The deterministic scenarios cover an accepted complete-bundle alert, +The committed `generated/alerts.contract.json` and `generated/alert-subscription.schema.json` +come from the adjacent source project. The record import checks the fake replies' API field +names while allowing fields hidden by access policy. `generated/notices.contract.json` comes +from the adjacent `notices` Messaging project and checks the notice's computed template data +and locale. Copy regenerated contracts into those paths when you change either owning project. +Messaging's own access profile still authorizes the live call. + +The deterministic scenarios cover an accepted complete-bundle alert, withdrawal after claim, a rescheduled appointment, cross-run dispatch suppression, and an uncertain Messaging answer recovered with `retry-same`. They do not prove live PostgreSQL guards, real signed grant status, Scheduling diff --git a/products/coordinator/examples/earlier-appointment-alerts/generated/alert-subscription.schema.json b/products/coordinator/examples/earlier-appointment-alerts/generated/alert-subscription.schema.json new file mode 100644 index 0000000000..33de2047f6 --- /dev/null +++ b/products/coordinator/examples/earlier-appointment-alerts/generated/alert-subscription.schema.json @@ -0,0 +1 @@ +{"$id":"urn:breg:record-type:alert-subscription","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"additionalOffering":{"maxLength":64,"minLength":1,"type":"string"},"additionalResource":{"maxLength":64,"minLength":1,"type":"string"},"additionalUnits":{"format":"int64","type":"integer"},"appointmentEnd":{"format":"date-time","type":"string"},"appointmentId":{"maxLength":120,"minLength":1,"type":"string"},"appointmentRevision":{"format":"int64","type":"integer"},"appointmentStart":{"format":"date-time","type":"string"},"conditionId":{"maxLength":120,"minLength":1,"type":"string"},"consent":{"type":"boolean"},"desiredStart":{"format":"date-time","type":"string"},"destination":{"maxLength":254,"minLength":3,"type":"string"},"durationMinutes":{"format":"int64","type":"integer"},"expiresAt":{"format":"date-time","type":"string"},"expiresOn":{"format":"date","type":"string"},"messageId":{"anyOf":[{"maxLength":120,"minLength":1,"type":"string"},{"type":"null"}]},"offering":{"maxLength":64,"minLength":1,"type":"string"},"partyAttendees":{"format":"int64","type":"integer"},"partyRecipients":{"format":"int64","type":"integer"},"resource":{"anyOf":[{"maxLength":64,"minLength":1,"type":"string"},{"type":"null"}]},"resourceUnits":{"format":"int64","type":"integer"},"scope":{"enum":["appointment"],"type":"string","x-registry-vocabulary":"alert-scope"},"source":{"maxLength":80,"minLength":1,"type":"string"},"state":{"enum":["active","claimed","dispatching","notified","withdrawn"],"type":"string","x-registry-vocabulary":"alert-state"}},"required":["source","conditionId","scope","state","consent","expiresAt","expiresOn","destination","appointmentId","appointmentRevision","appointmentStart","appointmentEnd","desiredStart","durationMinutes","offering","resourceUnits","partyRecipients","partyAttendees","additionalOffering","additionalResource","additionalUnits"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/coordinator/examples/earlier-appointment-alerts/generated/notices.contract.json b/products/coordinator/examples/earlier-appointment-alerts/generated/notices.contract.json new file mode 100644 index 0000000000..5e8ae53dec --- /dev/null +++ b/products/coordinator/examples/earlier-appointment-alerts/generated/notices.contract.json @@ -0,0 +1 @@ +{"apiVersion":"id.registrystack.org/formats/messaging/template-contracts/v1","kind":"MessagingTemplateContracts","templates":[{"id":"earlier-appointment","version":"1","locales":["en"],"dataSchema":{"additionalProperties":false,"properties":{"appointmentReference":{"minLength":1,"type":"string"},"availableStart":{"format":"date-time","type":"string"}},"required":["appointmentReference","availableStart"],"type":"object"}}]} diff --git a/products/coordinator/examples/earlier-appointment-alerts/notices/messaging.yaml b/products/coordinator/examples/earlier-appointment-alerts/notices/messaging.yaml new file mode 100644 index 0000000000..d46c1dbe40 --- /dev/null +++ b/products/coordinator/examples/earlier-appointment-alerts/notices/messaging.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://id.registrystack.org/schemas/messaging/project/project.v1alpha1.schema.json +apiVersion: id.registrystack.org/formats/messaging/project/v1alpha1 +kind: MessagingProject +project: + id: earlier-appointment-notices + version: '1' +providers: + - id: mail-relay + type: smtp +senderProfiles: + - id: transactional + channel: email + provider: mail-relay + sender: notices@example.invalid +templates: + - id: earlier-appointment + version: '1' +accessProfiles: + - id: alert-sender + principalClaim: sub + requiredScopes: [messaging:send] + requesterClients: [coordinator-alerts] + actorKind: service + role: sender + senderProfiles: [transactional] + templates: [earlier-appointment] + requestsPerMinute: 60 + burst: 10 diff --git a/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/en/subject.j2 b/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/en/subject.j2 new file mode 100644 index 0000000000..d0d0bd5c6e --- /dev/null +++ b/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/en/subject.j2 @@ -0,0 +1 @@ +An earlier appointment is available diff --git a/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/en/text.j2 b/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/en/text.j2 new file mode 100644 index 0000000000..0572a2b959 --- /dev/null +++ b/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/en/text.j2 @@ -0,0 +1 @@ +An earlier time is available for appointment {{ appointmentReference }}: {{ availableStart }}. diff --git a/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/schema.json b/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/schema.json new file mode 100644 index 0000000000..6fc0f2af31 --- /dev/null +++ b/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/schema.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "additionalProperties": false, + "required": ["appointmentReference", "availableStart"], + "properties": { + "appointmentReference": {"type": "string", "minLength": 1}, + "availableStart": {"type": "string", "format": "date-time"} + } +} diff --git a/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/template.yaml b/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/template.yaml new file mode 100644 index 0000000000..9e8625030f --- /dev/null +++ b/products/coordinator/examples/earlier-appointment-alerts/notices/templates/earlier-appointment/1/template.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://id.registrystack.org/schemas/messaging/template/template.v1alpha1.schema.json +apiVersion: id.registrystack.org/formats/messaging/template/v1alpha1 +kind: MessagingTemplate +channel: email +locales: [en] +parts: [subject, text] diff --git a/products/coordinator/examples/earlier-appointment-alerts/workflow.yaml b/products/coordinator/examples/earlier-appointment-alerts/workflow.yaml index 6bb7ffa385..282c245a2b 100644 --- a/products/coordinator/examples/earlier-appointment-alerts/workflow.yaml +++ b/products/coordinator/examples/earlier-appointment-alerts/workflow.yaml @@ -22,6 +22,12 @@ contracts: alerts: type: breg source: generated/alerts.contract.json + subscription-record: + type: breg + source: generated/alert-subscription.schema.json + notice-templates: + type: messaging + source: generated/notices.contract.json functions: source-route: runtime: rhai @@ -109,6 +115,7 @@ operations: alerts-read-record: uses: breg.read-record/v1 connection: alerts + record: {contract: subscription-record} alerts-claim: uses: breg.invoke-action/v1 connection: alerts @@ -130,6 +137,7 @@ operations: notices-submit-message: uses: messaging.submit-message/v1 connection: notices + template: {contract: notice-templates, id: earlier-appointment, version: '1'} workflow: input: {typeRef: workflow-input} deadlineSeconds: 600 diff --git a/products/coordinator/generated/operations/breg.read-record.v1.output.schema.json b/products/coordinator/generated/operations/breg.read-record.v1.output.schema.json index 3ba93ea694..ec2f7501bc 100644 --- a/products/coordinator/generated/operations/breg.read-record.v1.output.schema.json +++ b/products/coordinator/generated/operations/breg.read-record.v1.output.schema.json @@ -6,7 +6,7 @@ "properties": { "domainData": { "additionalProperties": true, - "description": "Project-defined record data. The registry project's entity schema owns these members, so check accepts any path beneath domainData and test and the runtime decide.", + "description": "Project-defined record data. An imported BReg record schema limits member names to the record type's API names. Fields may be hidden by access policy; value types remain open. Without a record import, any member name is accepted.", "type": "object" }, "recordIdentifier": { diff --git a/products/coordinator/generated/operations/catalog.json b/products/coordinator/generated/operations/catalog.json index 7b328fcaee..13dd038ec7 100644 --- a/products/coordinator/generated/operations/catalog.json +++ b/products/coordinator/generated/operations/catalog.json @@ -221,7 +221,7 @@ "properties": { "domainData": { "additionalProperties": true, - "description": "Project-defined record data. The registry project's entity schema owns these members, so check accepts any path beneath domainData and test and the runtime decide.", + "description": "Project-defined record data. An imported BReg record schema limits member names to the record type's API names. Fields may be hidden by access policy; value types remain open. Without a record import, any member name is accepted.", "type": "object" }, "recordIdentifier": { diff --git a/products/coordinator/generated/project/project.schema.json b/products/coordinator/generated/project/project.schema.json index 97a50591b5..2f12d0aa4a 100644 --- a/products/coordinator/generated/project/project.schema.json +++ b/products/coordinator/generated/project/project.schema.json @@ -295,10 +295,18 @@ "$ref": "#/$defs/LocalId", "description": "The typed pure Function that interprets an external command's provider\nresponse into an accepted result, a declared refusal or an uncertain\noutcome. Required on, and accepted only on, `external.command/v1`." }, + "record": { + "$ref": "#/$defs/RecordReference", + "description": "Limit BReg read replies to the imported record schema's API field names.\nFields may be hidden by access policy, and their value types remain open." + }, "responseSchema": { "$ref": "#/$defs/Schema", "description": "An optional typed response specialization for the bounded external GET\nor external command Operation." }, + "template": { + "$ref": "#/$defs/TemplateReference", + "description": "Fix a Messaging template version, its locales and its data schema." + }, "title": { "$ref": "#/$defs/DisplayText", "description": "An optional human title; it changes neither stable identity nor behavior revision." @@ -574,7 +582,8 @@ }, "ContractProduct": { "enum": [ - "breg" + "breg", + "messaging" ], "type": "string" }, @@ -748,6 +757,19 @@ ], "type": "object" }, + "RecordReference": { + "additionalProperties": false, + "properties": { + "contract": { + "$ref": "#/$defs/LocalId", + "description": "The local generated BReg record schema import." + } + }, + "required": [ + "contract" + ], + "type": "object" + }, "Reference": { "oneOf": [ { @@ -815,6 +837,29 @@ "Schema": { "description": "A whole JSON Schema or one compile-time schema reference. Schema keywords\nretain their independently owned JSON Schema spelling.", "x-registry-foreign": "json-schema-2020-12" + }, + "TemplateReference": { + "additionalProperties": false, + "properties": { + "contract": { + "$ref": "#/$defs/LocalId", + "description": "The local generated Messaging template contract import." + }, + "id": { + "$ref": "#/$defs/LocalId", + "description": "The exact template ID in the imported contract." + }, + "version": { + "description": "The exact template version label, quoted when numeric.", + "type": "string" + } + }, + "required": [ + "contract", + "id", + "version" + ], + "type": "object" } }, "$id": "https://id.registrystack.org/schemas/coordinator/project/project.v1alpha1.schema.json", diff --git a/products/coordinator/scripts/test-postgres.sh b/products/coordinator/scripts/test-postgres.sh index 5695dad457..7ec34d0673 100755 --- a/products/coordinator/scripts/test-postgres.sh +++ b/products/coordinator/scripts/test-postgres.sh @@ -30,7 +30,8 @@ export COORDINATOR_MESSAGINGCTL_BIN="$coordinator_test_target/debug/messagingctl export COORDINATOR_SCHEDULING_BIN="$coordinator_test_target/debug/scheduling" export COORDINATOR_SCHEDULINGCTL_BIN="$coordinator_test_target/debug/schedulingctl" cargo test --locked -p registry-coordinator --features postgres-test --lib \ - --test postgres_state --test recovery_audit --test messaging_integration --test process_restart \ + --test postgres_state --test recovery_audit --test read_lease_recovery \ + --test messaging_integration --test process_restart \ --test deployment_postgres --test review_wait_postgres \ --test external_wait_postgres --test external_command_postgres \ --test standalone_review_postgres diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 8d3e2c1f45..f5769cedc8 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -4648,11 +4648,11 @@ "description": "Registry Coordinator portable project authoring JSON Schema.", "source": { "path": "crates/registry-coordinator/src/authoring.rs", - "sha256": "a7e41c24afb242567c5a0a42b401a103a6e598426df89c9aa26e89902a5de9fb" + "sha256": "985cb3f7625b5126e856c7ac7aa77369a962348fab329d159c0f39b992a44173" }, "artifact": { "path": "products/coordinator/generated/project/project.schema.json", - "sha256": "c95bb112c47134d9ef0bfad1d03be54623be2a6f7ec1f9f2145f98b8622921f0", + "sha256": "f6a496af6ee1f6276342c84d0a862fdd44fe44e1d5c1c4d6daa512967a14851d", "mediaType": "application/schema+json" } }, diff --git a/products/messaging/README.md b/products/messaging/README.md index 2e9ec6c0a0..c1bd150350 100644 --- a/products/messaging/README.md +++ b/products/messaging/README.md @@ -249,6 +249,8 @@ secret references: ```bash messagingctl init ./notices messagingctl check --project ./notices +messagingctl generate templates --project ./notices \ + --output ./notices-template-contracts.json messagingctl package ./notices --output ./notices-installed messagingctl check --package ./notices-installed messagingctl check --runtime-config /abs/path/runtime.yaml @@ -261,6 +263,18 @@ messagingctl status --runtime-config /abs/path/runtime.yaml messaging --runtime-config /abs/path/runtime.yaml serve ``` +`messagingctl generate templates --project PROJECT --output FILE` validates the +editable project, then creates a deterministic `MessagingTemplateContracts` +JSON document for another tool. It contains one entry per template version, +with its `id`, string `version`, sorted `locales`, and checked `schema.json` +value as `dataSchema`. The export is separate from the immutable +package inputs and never changes the package digest; an existing output file +is refused rather than overwritten. + +Use local JSON Pointer references, such as `#/$defs/name`, in template data +schemas. The current schema validator does not support named anchors or dynamic +references. + `--data` names a JSON file, read relative to the directory the command runs in like any other relative path; each template version's `sample.json` serves. `messagingctl plan` and `status` use the runtime credential in diff --git a/products/platform/config-formats.yaml b/products/platform/config-formats.yaml index 48f18e68aa..2305e603f5 100644 --- a/products/platform/config-formats.yaml +++ b/products/platform/config-formats.yaml @@ -1874,6 +1874,37 @@ formats: boolean: none securityMembers: [] restrictingMembers: [] + - id: messaging/template-contracts + product: messaging + title: Messaging template contracts + files: ['*.contract.json'] + syntax: json + audience: generated + stability: unpromised + current: + apiVersion: id.registrystack.org/formats/messaging/template-contracts/v1 + kind: MessagingTemplateContracts + checkedBy: + file: crates/registry-coordinator/src/contracts.rs + symbol: is_messaging_template_export + target: + apiVersion: id.registrystack.org/formats/messaging/template-contracts/v1 + kind: MessagingTemplateContracts + schema: none + reader: + crate: registry-coordinator + file: crates/registry-coordinator/src/authoring.rs + function: compile + type: untyped + check: coordinatorctl check --project {project} + example: products/coordinator/examples/earlier-appointment-alerts/generated/notices.contract.json + conformance: + requiredText: none + optionalText: none + integer: none + boolean: none + securityMembers: [] + restrictingMembers: [] - id: messaging/ctl-report product: messaging title: messagingctl --format json report diff --git a/products/platform/conformance/yaml/expected-failures.yaml b/products/platform/conformance/yaml/expected-failures.yaml index 62b614e003..4b4e0a9a2e 100644 --- a/products/platform/conformance/yaml/expected-failures.yaml +++ b/products/platform/conformance/yaml/expected-failures.yaml @@ -55,6 +55,8 @@ unreachedFormats: reason: syntax json; the corpus mutates YAML text - format: messaging/dev-state reason: syntax json; the corpus mutates YAML text +- format: messaging/template-contracts + reason: syntax json; the corpus mutates YAML text - format: platform/thunderid-session reason: syntax json; the corpus mutates YAML text inapplicableCases: []