diff --git a/Cargo.lock b/Cargo.lock index 0b610cf228..6ac6685446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4016,9 +4016,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" dependencies = [ "cc", "pkg-config", @@ -6665,6 +6665,7 @@ dependencies = [ "registry-platform-audit", "registry-platform-authcommon", "registry-platform-buildinfo", + "registry-platform-calendar", "registry-platform-canonical-json", "registry-platform-config", "registry-platform-crypto", @@ -8536,9 +8537,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.7.0" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ece64e19631fc38758181e637c15509a2c2709ee53bfde1e1d58f9dc991f919e" +checksum = "d24a7e92f4bcaa36b97fa93c4fa7ca8f3e669eced949345b3462dba333c7f909" dependencies = [ "async-trait", "base64 0.23.1", @@ -8557,6 +8558,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2 0.11.1", "sse-stream", "thiserror 2.0.21", "tokio", @@ -8590,9 +8592,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.40.2" +version = "0.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" dependencies = [ "bitflags", "fallible-iterator 0.3.0", @@ -9769,7 +9771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 86fc6a2417..4536e3a3bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -298,7 +298,7 @@ rcgen = { version = "0.14", default-features = false, features = ["ring", "zeroi # perf extensions are not. regex = { version = "1", default-features = false, features = ["std", "unicode"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] } -rmcp = { version = "3.4.1", default-features = false } +rmcp = { version = "3.9.0", default-features = false } # Keep Rhai strings off smartstring's reported unsound conversion path. # `internals` exposes the syntax tree and the registered-function table, which # the Coordinator reads to refuse calls to helpers its pure profile lacks; the @@ -308,7 +308,7 @@ rustls = { version = "0.23", default-features = false, features = ["std", "ring" # `bundled` vendors the SQLite amalgamation, so a statement source needs no # system library. `hooks` carries the authorizer and the progress handler, which # are the statement source's safety boundary and its only real cancellation. -rusqlite = { version = "0.40", default-features = false, features = ["bundled", "hooks", "limits"] } +rusqlite = { version = "0.41.1", default-features = false, features = ["bundled", "hooks", "limits"] } rustix = { version = "1", features = ["fs", "process"] } # SSH uses AWS-LC ciphers and RustCrypto KEX/signatures, not whole-session FIPS. russh = { version = "=0.65.0", default-features = false, features = ["aws-lc-rs"] } diff --git a/crates/registry-breg/src/action_evidence.rs b/crates/registry-breg/src/action_evidence.rs index 33db39e33f..dcdcb1c308 100644 --- a/crates/registry-breg/src/action_evidence.rs +++ b/crates/registry-breg/src/action_evidence.rs @@ -50,6 +50,7 @@ impl ActionEvidenceEvaluator { &self, action: CompiledAction, inputs: Map, + evaluation_date: Option, deadline: Instant, ) -> FrozenEvidenceEvaluation { let unavailable = || FrozenEvidenceEvaluation { @@ -118,6 +119,7 @@ impl ActionEvidenceEvaluator { let outcome = crate::action_handler::evaluate_with_engine( &action, &inputs, + evaluation_date, deadline, Some(Arc::new(resolver)), Some(cancelled.clone()), diff --git a/crates/registry-breg/src/action_handler.rs b/crates/registry-breg/src/action_handler.rs index e93e6cf005..413f76d1ac 100644 --- a/crates/registry-breg/src/action_handler.rs +++ b/crates/registry-breg/src/action_handler.rs @@ -8,7 +8,7 @@ use crate::{ rhai_planner::{self, PlanError}, }; #[cfg(feature = "postgres-test")] -use std::collections::BTreeMap; +use std::collections::{BTreeMap, VecDeque}; use registry_platform_script::rhai as platform; use rhai::{Dynamic, Map, AST}; @@ -195,6 +195,98 @@ pub(crate) fn compile_source_for_function( Ok(ast) } +pub(crate) fn source_uses_today(source: &str, _entrypoint: &str) -> bool { + // Inspect calls as authored. The ordinary compilation path optimizes the + // AST, which may fold or rewrite a zero-argument helper call before this + // metadata pass sees it. The fixed date only registers `today`; it is + // never evaluated while compiling. + let mut engine = + rhai_planner::engine_with_date(None, Some(time::macros::date!(2000 - 01 - 01))); + engine.set_optimization_level(rhai::OptimizationLevel::None); + let Ok(ast) = engine.compile(source) else { + return false; + }; + if ast + .iter_functions() + .any(|function| function.name == "today" && function.params.is_empty()) + { + return false; + } + let mut uses_today = false; + ast.walk(&mut |nodes| { + let direct_today = match nodes.last() { + Some(rhai::ASTNode::Expr(rhai::Expr::FnCall(call, _))) + | Some(rhai::ASTNode::Stmt(rhai::Stmt::FnCall(call, _))) => { + !call.is_qualified() && call.name == "today" && call.args.is_empty() + } + _ => false, + }; + let is_today = direct_today + || crate::rhai_literal_lint::literal_function_pointer(nodes) + .is_some_and(|(function, _)| function == "today"); + uses_today |= is_today; + !uses_today + }); + uses_today +} + +#[cfg(feature = "runtime")] +pub(crate) fn trusted_evaluation_date( + action: &CompiledAction, + registry: &CompiledRegistry, + now: time::OffsetDateTime, +) -> Result, ActionHandlerError> { + if !action + .handler + .as_ref() + .is_some_and(|handler| handler.uses_today) + { + return Ok(None); + } + #[cfg(feature = "postgres-test")] + if let Some(date) = TEST_EVALUATION_DATES + .lock() + .unwrap() + .get_mut(&action.definition_revision) + .and_then(VecDeque::pop_front) + { + return Ok(Some(date)); + } + use chrono::Datelike as _; + let local = registry + .local_date_at(now) + .map_err(|_| ActionHandlerError::Execution)?; + let month = + time::Month::try_from(local.month() as u8).map_err(|_| ActionHandlerError::Execution)?; + time::Date::from_calendar_date(local.year(), month, local.day() as u8) + .map(Some) + .map_err(|_| ActionHandlerError::Execution) +} + +#[cfg(feature = "postgres-test")] +static TEST_EVALUATION_DATES: std::sync::Mutex>> = + std::sync::Mutex::new(BTreeMap::new()); + +/// Supply trusted dates for one exact compiled action revision. A queue makes +/// it possible for PostgreSQL tests to prove which transaction evaluates a +/// plan without replacing the runtime clock or weakening the registry zone. +#[cfg(feature = "postgres-test")] +pub fn set_test_action_evaluation_dates(action: &CompiledAction, dates: Vec) { + TEST_EVALUATION_DATES + .lock() + .unwrap() + .insert(action.definition_revision.clone(), dates.into()); +} + +#[cfg(feature = "postgres-test")] +pub fn remaining_test_action_evaluation_dates(action: &CompiledAction) -> usize { + TEST_EVALUATION_DATES + .lock() + .unwrap() + .get(&action.definition_revision) + .map_or(0, VecDeque::len) +} + #[cfg(feature = "postgres-test")] static TEST_INVOCATIONS: std::sync::Mutex> = std::sync::Mutex::new(BTreeMap::new()); @@ -243,6 +335,18 @@ pub fn evaluate_action( evaluate_action_detailed(action, inputs, deadline).map_err(|diagnostic| diagnostic.kind) } +/// Evaluate with one trusted registry-local date. The date is captured by the +/// caller once and is the only value `today()` can return during this plan. +pub fn evaluate_action_at_date( + action: &CompiledAction, + inputs: &JsonMap, + evaluation_date: time::Date, + deadline: Instant, +) -> Result { + evaluate_action_detailed_at_date(action, inputs, evaluation_date, deadline) + .map_err(|diagnostic| diagnostic.kind) +} + /// The shared handler input rule for HTTP, direct invocation and local tests. /// Callers admit optional null separately. The byte check also covers scalar /// parsers, such as RFC3339, that accept arbitrarily long fractional seconds. @@ -275,6 +379,19 @@ pub fn evaluate_action_detailed( evaluate_admitted_action_detailed(action, inputs, deadline) } +pub fn evaluate_action_detailed_at_date( + action: &CompiledAction, + inputs: &JsonMap, + evaluation_date: time::Date, + deadline: Instant, +) -> Result { + if Instant::now() >= deadline { + return Err(ActionHandlerError::Deadline.into()); + } + validate_inputs(action, inputs)?; + evaluate_admitted_action_detailed_at_date(action, inputs, evaluation_date, deadline) +} + fn validate_inputs( action: &CompiledAction, inputs: &JsonMap, @@ -323,7 +440,25 @@ pub(crate) fn evaluate_admitted_action_detailed( inputs: &JsonMap, deadline: Instant, ) -> Result { - evaluate_with_engine(action, inputs, deadline, None, None) + evaluate_with_engine(action, inputs, None, deadline, None, None) +} + +pub(crate) fn evaluate_admitted_action_detailed_at_date( + action: &CompiledAction, + inputs: &JsonMap, + evaluation_date: time::Date, + deadline: Instant, +) -> Result { + evaluate_admitted_action_detailed_with_date(action, inputs, Some(evaluation_date), deadline) +} + +pub(crate) fn evaluate_admitted_action_detailed_with_date( + action: &CompiledAction, + inputs: &JsonMap, + evaluation_date: Option, + deadline: Instant, +) -> Result { + evaluate_with_engine(action, inputs, evaluation_date, deadline, None, None) } /// Both Action modes evaluate the same plan. Fixed plans never run a function; @@ -332,6 +467,7 @@ pub(crate) fn evaluate_admitted_action_detailed( pub(crate) fn evaluate_plan_detailed( action: &CompiledAction, inputs: &JsonMap, + evaluation_date: Option, deadline: Instant, ) -> Result { validate_inputs(action, inputs)?; @@ -343,7 +479,7 @@ pub(crate) fn evaluate_plan_detailed( .map(ActionHandlerOutcome::Effects) .map_err(|_| ActionHandlerError::Result.into()); } - evaluate_with_engine(action, inputs, deadline, None, None) + evaluate_with_engine(action, inputs, evaluation_date, deadline, None, None) } type EvidenceResolver = @@ -361,6 +497,25 @@ pub fn evaluate_action_with_evidence( evaluate_with_engine( action, inputs, + None, + deadline, + Some(std::sync::Arc::new(resolver)), + None, + ) +} + +pub fn evaluate_action_with_evidence_at_date( + action: &CompiledAction, + inputs: &JsonMap, + evaluation_date: time::Date, + deadline: Instant, + resolver: impl Fn(&str, Value) -> Result + Send + Sync + 'static, +) -> Result { + validate_inputs(action, inputs)?; + evaluate_with_engine( + action, + inputs, + Some(evaluation_date), deadline, Some(std::sync::Arc::new(resolver)), None, @@ -374,6 +529,7 @@ mod action_handler_benchmark_tests; pub(crate) fn evaluate_with_engine( action: &CompiledAction, inputs: &JsonMap, + evaluation_date: Option, deadline: Instant, resolver: Option, cancellation: Option>, @@ -395,6 +551,7 @@ pub(crate) fn evaluate_with_engine( handler, &ast, inputs, + evaluation_date, deadline, resolver, cancellation, @@ -464,11 +621,16 @@ fn admit_handler<'a>( /// engine with its resolver registration shape, the bounded call, and result /// decoding. Shared by the fresh-compile path and the cached-program /// benchmark variant. +#[expect( + clippy::too_many_arguments, + reason = "The checked program, trusted date, deadline and cancellation are independent inputs." +)] fn execute_compiled_handler( action: &CompiledAction, handler: &CompiledPlanFunction, ast: &AST, inputs: &JsonMap, + evaluation_date: Option, deadline: Instant, resolver: Option, cancellation: Option>, @@ -493,7 +655,8 @@ fn execute_compiled_handler( } std::mem::take(&mut entry.2) }; - let mut engine = rhai_planner::function_engine(deadline, handler.limits.as_ref()); + let mut engine = + rhai_planner::function_engine(deadline, handler.limits.as_ref(), evaluation_date); let poisoned = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let failed_capability = std::sync::Arc::new(std::sync::Mutex::new(None::)); let dependency_diagnostic = || { @@ -733,6 +896,7 @@ pub(crate) fn evaluate_with_cached_program( handler, ast, inputs, + None, deadline, resolver, cancellation, diff --git a/crates/registry-breg/src/api/actions.rs b/crates/registry-breg/src/api/actions.rs index 45e02a2ccd..eee344b71a 100644 --- a/crates/registry-breg/src/api/actions.rs +++ b/crates/registry-breg/src/api/actions.rs @@ -234,7 +234,7 @@ fn authorize_action<'a>( target_authority, grant.results.clone(), ) - .with_grant_audit(claims) + .with_audit_context(claims) .with_task_grant(task_grant), }) } @@ -446,7 +446,7 @@ async fn action_refusal( &route.action_id, crate::audit::HttpRefusalAudit { refusal_reason: None, - grant: crate::audit::GrantAuditContext::from_claims(claims), + context: crate::audit::RequestAuditContext::from_claims(claims), method: route.method.into(), operation_id: &route.id, target_record: None, diff --git a/crates/registry-breg/src/api/context.rs b/crates/registry-breg/src/api/context.rs index 03e5aa6fce..436feb776e 100644 --- a/crates/registry-breg/src/api/context.rs +++ b/crates/registry-breg/src/api/context.rs @@ -341,7 +341,7 @@ pub struct AuthorizedRequestContext { selected_profile: String, row_boundaries: Vec, task_grant: Option, - grant_audit: Option, + audit_context: Option, human_identity: Option, recipients: BTreeSet, } @@ -361,7 +361,7 @@ impl AuthorizedRequestContext { selected_profile, row_boundaries, task_grant: None, - grant_audit: None, + audit_context: None, human_identity: None, recipients: BTreeSet::new(), } @@ -378,8 +378,8 @@ impl AuthorizedRequestContext { &self.recipients } - pub(crate) fn with_grant_audit(mut self, claims: &VerifiedRequestClaims) -> Self { - self.grant_audit = crate::audit::GrantAuditContext::from_claims(claims); + pub(crate) fn with_audit_context(mut self, claims: &VerifiedRequestClaims) -> Self { + self.audit_context = crate::audit::RequestAuditContext::from_claims(claims); self.human_identity = claims.human_identity().cloned(); self.requester_client = claims.requester_client().map(str::to_owned); self @@ -404,8 +404,8 @@ impl AuthorizedRequestContext { crate::subject_access_log::forwarded_attribution(entity, self, headers)?; Ok(()) } - pub(crate) fn grant_audit(&self) -> Option<&crate::audit::GrantAuditContext> { - self.grant_audit.as_ref() + pub(crate) fn audit_context(&self) -> Option<&crate::audit::RequestAuditContext> { + self.audit_context.as_ref() } pub(crate) fn human_identity(&self) -> Option<®istry_review_client::HumanIdentity> { self.human_identity.as_ref() @@ -470,7 +470,7 @@ pub struct AuthorizedActionContext { selected_profile: String, target_authority: BTreeMap>, result_effects: BTreeSet, - grant_audit: Option, + audit_context: Option, task_grant: Option, } @@ -490,17 +490,17 @@ impl AuthorizedActionContext { selected_profile, target_authority, result_effects, - grant_audit: None, + audit_context: None, task_grant: None, } } - pub(crate) fn with_grant_audit(mut self, claims: &VerifiedRequestClaims) -> Self { - self.grant_audit = crate::audit::GrantAuditContext::from_claims(claims); + pub(crate) fn with_audit_context(mut self, claims: &VerifiedRequestClaims) -> Self { + self.audit_context = crate::audit::RequestAuditContext::from_claims(claims); self } - pub(crate) fn grant_audit(&self) -> Option<&crate::audit::GrantAuditContext> { - self.grant_audit.as_ref() + pub(crate) fn audit_context(&self) -> Option<&crate::audit::RequestAuditContext> { + self.audit_context.as_ref() } pub(crate) fn with_task_grant(mut self, grant: Option) -> Self { diff --git a/crates/registry-breg/src/api/mod.rs b/crates/registry-breg/src/api/mod.rs index 14f25bc342..852cd34c02 100644 --- a/crates/registry-breg/src/api/mod.rs +++ b/crates/registry-breg/src/api/mod.rs @@ -1567,6 +1567,7 @@ async fn audited_known_revision_refusal( operation_id: route.id.clone(), target_record: target_record.cloned(), principal: claims.principal().map(str::to_owned), + requester_client: claims.requester_client().map(str::to_owned), selected_access_profile: None, purpose_present: claims.purpose().is_some(), correlation: correlation.clone(), @@ -1593,6 +1594,7 @@ async fn audited_revision_refusal( operation_id: route.id.clone(), target_record: target_record.cloned(), principal: surface.context.principal().map(str::to_owned), + requester_client: surface.context.requester_client().map(str::to_owned), selected_access_profile: Some(surface.context.selected_profile().to_owned()), purpose_present: surface.context.purpose().is_some(), correlation: correlation.clone(), @@ -1627,6 +1629,7 @@ async fn audited_revision_concealment( operation_id: route.id.clone(), target_record: target_record.cloned(), principal: claims.principal().map(str::to_owned), + requester_client: claims.requester_client().map(str::to_owned), selected_access_profile, purpose_present: claims.purpose().is_some(), correlation: correlation.clone(), @@ -1655,6 +1658,7 @@ async fn audited_known_read_refusal( operation_id: route.id.clone(), target_record: target_record.cloned(), principal: claims.principal().map(str::to_owned), + requester_client: claims.requester_client().map(str::to_owned), selected_access_profile: None, purpose_present: claims.purpose().is_some(), correlation: correlation.clone(), @@ -1705,6 +1709,7 @@ async fn audited_read_refusal_with_reason( operation_id: route.id.clone(), target_record: target_record.cloned(), principal: surface.context.principal().map(str::to_owned), + requester_client: surface.context.requester_client().map(str::to_owned), selected_access_profile: Some(surface.context.selected_profile().to_owned()), purpose_present: surface.context.purpose().is_some(), correlation: correlation.clone(), @@ -1742,6 +1747,7 @@ async fn audited_read_concealment( operation_id: route.id.clone(), target_record: target_record.cloned(), principal: claims.principal().map(str::to_owned), + requester_client: claims.requester_client().map(str::to_owned), selected_access_profile, purpose_present: claims.purpose().is_some(), correlation: correlation.clone(), @@ -2309,7 +2315,7 @@ async fn audited_mutation_refusal( match mutations .record_refusal(crate::audit::HttpRefusalAudit { refusal_reason: None, - grant: context.grant_audit().cloned(), + context: context.audit_context().cloned(), method: route.method.into(), operation_id: &route.id, target_record, @@ -2347,7 +2353,7 @@ async fn audited_mutation_concealment( match mutations .record_refusal(crate::audit::HttpRefusalAudit { refusal_reason: None, - grant: crate::audit::GrantAuditContext::from_claims(claims), + context: crate::audit::RequestAuditContext::from_claims(claims), method: route.method.into(), operation_id: &route.id, target_record, @@ -2578,7 +2584,7 @@ fn authorize_direct_route<'a>( .with_task_grant( task_grant_binding(profile, claims).map_err(|_| ReadRefusalReason::ProfileAdmission)?, ) - .with_grant_audit(claims) + .with_audit_context(claims) .with_recipients(claims); Ok(AuthorizedSurface { route, @@ -2646,7 +2652,7 @@ fn authorize_read_path_route<'a>( .with_task_grant( task_grant_binding(profile, claims).map_err(|_| ReadRefusalReason::ProfileAdmission)?, ) - .with_grant_audit(claims) + .with_audit_context(claims) .with_recipients(claims), readable_fields, read_path: Some(read_path), diff --git a/crates/registry-breg/src/api/proposals.rs b/crates/registry-breg/src/api/proposals.rs index 50357b518e..33e5b067e4 100644 --- a/crates/registry-breg/src/api/proposals.rs +++ b/crates/registry-breg/src/api/proposals.rs @@ -355,7 +355,7 @@ pub(crate) fn authorize( .ok()?; authority.common = authority .common - .with_grant_audit(crate::audit::GrantAuditContext::from_claims(claims)); + .with_audit_context(crate::audit::RequestAuditContext::from_claims(claims)); match (task_source, claims.grant()) { (None, None) => {} (Some(source), Some(grant)) => { @@ -503,7 +503,7 @@ pub(super) async fn known_action_refusal( &action.id, crate::audit::HttpRefusalAudit { refusal_reason: None, - grant: crate::audit::GrantAuditContext::from_claims(claims), + context: crate::audit::RequestAuditContext::from_claims(claims), method: method.into(), operation_id: &operation_id, target_record: None, @@ -543,7 +543,7 @@ async fn audited_list_concealment( match mutations .record_refusal(crate::audit::HttpRefusalAudit { refusal_reason: None, - grant: crate::audit::GrantAuditContext::from_claims(claims), + context: crate::audit::RequestAuditContext::from_claims(claims), method: HttpMethod::Get.into(), operation_id: "proposals.list", target_record: None, diff --git a/crates/registry-breg/src/api/service.rs b/crates/registry-breg/src/api/service.rs index d170c59c0a..1ad06fa219 100644 --- a/crates/registry-breg/src/api/service.rs +++ b/crates/registry-breg/src/api/service.rs @@ -595,6 +595,7 @@ pub struct RecordReadRefusal { pub operation_id: String, pub target_record: Option, pub principal: Option, + pub requester_client: Option, pub selected_access_profile: Option, pub purpose_present: bool, pub correlation: RequestCorrelation, @@ -665,6 +666,7 @@ pub struct RevisionReadRefusal { pub operation_id: String, pub target_record: Option, pub principal: Option, + pub requester_client: Option, pub selected_access_profile: Option, pub purpose_present: bool, pub correlation: RequestCorrelation, diff --git a/crates/registry-breg/src/api/statistics.rs b/crates/registry-breg/src/api/statistics.rs index b45e6104a1..976df1c3d7 100644 --- a/crates/registry-breg/src/api/statistics.rs +++ b/crates/registry-breg/src/api/statistics.rs @@ -202,7 +202,7 @@ fn authorize_with_reason( .with_task_grant( task_grant_binding(profile, claims).map_err(|_| ReadRefusalReason::ProfileAdmission)?, ) - .with_grant_audit(claims) + .with_audit_context(claims) .with_recipients(claims)) } @@ -347,7 +347,7 @@ async fn refusal_for_operation( backend.expected(), HttpRefusalAudit { refusal_reason: reason, - grant: crate::audit::GrantAuditContext::from_claims(claims), + context: crate::audit::RequestAuditContext::from_claims(claims), method, operation_id, target_record: None, @@ -521,7 +521,7 @@ async fn dispatch( StatisticsTerminalAudit { read: ReadTerminalAudit { terminal: TerminalAudit { - grant: context.grant_audit().cloned(), + context: context.audit_context().cloned(), outcome: if success { if route.kind.method() == HttpMethod::Post { if lifecycle.as_ref().is_some_and(|l: &Lifecycle| l.replayed) { diff --git a/crates/registry-breg/src/audit.rs b/crates/registry-breg/src/audit.rs index 644ed5edac..d53a299ecd 100644 --- a/crates/registry-breg/src/audit.rs +++ b/crates/registry-breg/src/audit.rs @@ -127,12 +127,11 @@ impl RegistryAudit { } } -/// Verified task grant or delegated actor retained only for minimized, keyed -/// audit projection. +/// Verified client and any contextual authority retained for keyed audit projection. #[derive(Clone, Eq, PartialEq)] -pub(crate) struct GrantAuditContext { - actor_kind: registry_platform_oidc::ActorKind, - authority: AuditedAuthority, +pub(crate) struct RequestAuditContext { + client: String, + authorization: Option<(registry_platform_oidc::ActorKind, AuditedAuthority)>, } #[derive(Clone, Eq, PartialEq)] enum AuditedAuthority { @@ -142,75 +141,79 @@ enum AuditedAuthority { }, Delegated { principal: String, - client: String, actor: String, }, } -impl std::fmt::Debug for GrantAuditContext { +impl std::fmt::Debug for RequestAuditContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("GrantAuditContext()") + f.write_str("RequestAuditContext()") } } -impl GrantAuditContext { - /// Retains a task grant, a verified trusted actor, or both. A direct token - /// carrying neither yields nothing, so its audit is unchanged. +impl RequestAuditContext { pub(crate) fn from_claims(claims: &crate::api::VerifiedRequestClaims) -> Option { + let client = claims.requester_client()?.to_owned(); let actor = claims.actor_subject().map(str::to_owned); let authority = match (claims.grant(), actor) { - (Some(grant), actor) => AuditedAuthority::Grant { + (Some(grant), actor) => Some(AuditedAuthority::Grant { grant: grant.clone(), actor, - }, - (None, Some(actor)) => AuditedAuthority::Delegated { + }), + (None, Some(actor)) => Some(AuditedAuthority::Delegated { principal: claims.principal()?.to_owned(), - client: claims.requester_client()?.to_owned(), actor, - }, - (None, None) => return None, + }), + (None, None) => None, }; Some(Self { - actor_kind: claims.actor_kind()?, - authority, + client, + authorization: claims.actor_kind().zip(authority), }) } - fn record( - &self, + pub(crate) fn client_only(client: &str) -> Self { + Self { + client: client.to_owned(), + authorization: None, + } + } + + fn fields( + context: Option<&Self>, profile: &AuditProfile, scope: &str, operation: &str, - allowed: bool, - ) -> Result { + allowed: Option, + ) -> Result, RegistryAuditError> { use registry_platform_audit::{AuthorizationAuditEvent, AuthorizationOutcome}; + let Some(context) = context else { + return Ok(serde_json::Map::new()); + }; let hasher = profile.key_hasher(); let pseudonym = |domain, value| { hasher .audit_reference_hash(domain, scope, value) .map_err(|_| RegistryAuditError::InvalidContext) }; - let (principal, client, grant, actor) = match &self.authority { - AuditedAuthority::Grant { grant, actor } => ( - grant.principal(), - grant.client(), - Some(grant), - actor.as_deref(), - ), - AuditedAuthority::Delegated { - principal, - client, - actor, - } => ( - principal.as_str(), - client.as_str(), - None, - Some(actor.as_str()), - ), + let client_reference = pseudonym("breg-client-v1", &context.client)?; + let mut fields = + serde_json::Map::from_iter([("clientReference".to_owned(), json!(client_reference))]); + let (Some((actor_kind, authority)), Some(allowed)) = (&context.authorization, allowed) + else { + return Ok(fields); + }; + let (principal, grant, actor) = match authority { + AuditedAuthority::Grant { grant, actor } => { + (grant.principal(), Some(grant), actor.as_deref()) + } + AuditedAuthority::Delegated { principal, actor } => { + (principal.as_str(), None, Some(actor.as_str())) + } }; // BREG records purpose presence, never the purpose value. let event = AuthorizationAuditEvent::without_purpose( - self.actor_kind.as_str(), + actor_kind.as_str(), pseudonym("breg-principal-v1", principal)?, - pseudonym("breg-client-v1", client)?, + client_reference, grant .map(|grant| pseudonym("breg-grant-v1", grant.id())) .transpose()?, @@ -240,7 +243,8 @@ impl GrantAuditContext { if let Some(actor) = actor { value["actorPseudonym"] = json!(pseudonym("breg-actor-v1", actor)?); } - Ok(value) + fields.insert("authorization".to_owned(), value); + Ok(fields) } } @@ -330,7 +334,7 @@ impl ReadRefusalReason { pub(crate) struct HttpRefusalAudit<'a> { pub refusal_reason: Option, - pub grant: Option, + pub context: Option, pub method: RefusalHttpMethod, pub operation_id: &'a str, pub target_record: Option<&'a str>, @@ -350,7 +354,7 @@ pub enum RegistryAuditError { } pub(crate) struct TerminalAudit { - pub grant: Option, + pub context: Option, pub outcome: TerminalAuditOutcome, pub method: HttpMethod, pub operation_id: String, @@ -447,22 +451,26 @@ pub(crate) async fn begin_statistics_pre_io_audit( }) .transpose() .map_err(|_| RegistryAuditError::InvalidContext)?; - begin_attempt( - audit, - event.correlation, - json!({ - "phase":"attempt", "method":method_name(event.method), - "operationId":event.operation_id, - "requestId":event.correlation.request_id().to_string(), - "traceId":event.correlation.trace_id().as_str(), - "activationId":expected.activation_id, - "selectedAccessProfile":context.selected_profile(), - "purposePresent":context.purpose().is_some(), - "principalReference":principal_reference, - "recordReference":null, - }), - ) - .await + let mut record = json!({ + "phase":"attempt", "method":method_name(event.method), + "operationId":event.operation_id, + "requestId":event.correlation.request_id().to_string(), + "traceId":event.correlation.trace_id().as_str(), + "activationId":expected.activation_id, + "selectedAccessProfile":context.selected_profile(), + "purposePresent":context.purpose().is_some(), + "principalReference":principal_reference, + "recordReference":null, + }); + extend_pre_io_context( + &mut record, + context.audit_context(), + audit.profile(), + expected, + &event, + None, + )?; + begin_attempt(audit, event.correlation, record).await } pub(crate) struct StatisticsTerminalAudit<'a> { @@ -561,12 +569,14 @@ fn pre_io_record( "principalReference": principal_reference, "recordReference": record_reference, }); - if event.kind == PreIoAuditKind::Refusal { - if let Some(grant) = claims.grant_audit() { - record["authorization"] = - grant.record(profile, &expected.activation_id, event.operation_id, false)?; - } - } + extend_pre_io_context( + &mut record, + claims.audit_context(), + profile, + expected, + event, + (event.kind == PreIoAuditKind::Refusal).then_some(false), + )?; insert_refusal_reason(&mut record, event.refusal_reason); Ok(record) } @@ -711,10 +721,39 @@ fn action_resource_pre_io_record( "principalReference": principal_reference, "actionId": claims.action_id(), }); + extend_pre_io_context( + &mut record, + claims.audit_context(), + profile, + expected, + event, + None, + )?; insert_refusal_reason(&mut record, event.refusal_reason); Ok(record) } +fn extend_pre_io_context( + record: &mut Value, + context: Option<&RequestAuditContext>, + profile: &AuditProfile, + expected: &ExpectedRegistryIdentity, + event: &PreIoAudit<'_>, + allowed: Option, +) -> Result<(), RegistryAuditError> { + record + .as_object_mut() + .ok_or(RegistryAuditError::InvalidContext)? + .extend(RequestAuditContext::fields( + context, + profile, + &expected.activation_id, + event.operation_id, + allowed, + )?); + Ok(()) +} + /// Append `record` as the attempt `request` entry of `correlation`. async fn begin_attempt( audit: &RegistryAudit, @@ -735,8 +774,8 @@ async fn begin_attempt( } /// The `response` an attempt writes when its request ends without a -/// terminal or refusal entry: the operation and request it answers, and -/// nothing the request read or was about to write. +/// terminal or refusal entry: the operation, request and client reference, +/// and nothing the request read or was about to write. fn unfinished_record(attempt: &Value) -> Value { let mut record = serde_json::Map::new(); record.insert("phase".to_owned(), json!("unfinished")); @@ -747,6 +786,7 @@ fn unfinished_record(attempt: &Value) -> Value { "traceId", "activationId", "actionId", + "clientReference", ] { if let Some(value) = attempt.get(field) { record.insert(field.to_owned(), value.clone()); @@ -843,12 +883,13 @@ async fn record_http_refusal_audit_inner( Value::Bool(event.purpose_present), ), ]); - if let Some(grant) = &event.grant { - record.insert( - "authorization".to_owned(), - grant.record(profile, &expected.activation_id, event.operation_id, false)?, - ); - } + record.extend(RequestAuditContext::fields( + event.context.as_ref(), + profile, + &expected.activation_id, + event.operation_id, + Some(false), + )?); if let Some(reason) = event.refusal_reason { record.insert( "refusalReason".to_owned(), @@ -1015,18 +1056,13 @@ fn terminal_record( terminal: TerminalAudit, profile: &AuditProfile, ) -> Result, RegistryAuditError> { - let authorization = terminal - .grant - .as_ref() - .map(|grant| { - grant.record( - profile, - &terminal.package_revision, - &terminal.operation_id, - terminal.outcome != TerminalAuditOutcome::Refused, - ) - }) - .transpose()?; + let context = RequestAuditContext::fields( + terminal.context.as_ref(), + profile, + &terminal.package_revision, + &terminal.operation_id, + Some(terminal.outcome != TerminalAuditOutcome::Refused), + )?; let mut record = serde_json::Map::from_iter([ ("phase".to_owned(), Value::String("terminal".to_owned())), ( @@ -1072,9 +1108,7 @@ fn terminal_record( Value::Bool(terminal.purpose_present), ), ]); - if let Some(authorization) = authorization { - record.insert("authorization".to_owned(), authorization); - } + record.extend(context); if let Some(entity_id) = terminal.entity_id { record.insert("entityId".to_owned(), Value::String(entity_id)); } @@ -1304,7 +1338,7 @@ mod action_terminal_tests { fn terminal(outcome: TerminalAuditOutcome) -> TerminalAudit { TerminalAudit { - grant: None, + context: None, outcome, method: HttpMethod::Post, operation_id: "actions.register.invoke".to_owned(), @@ -1556,9 +1590,12 @@ mod delegated_actor_audit_tests { .unwrap() } - fn terminal(grant: Option, outcome: TerminalAuditOutcome) -> TerminalAudit { + fn terminal( + context: Option, + outcome: TerminalAuditOutcome, + ) -> TerminalAudit { TerminalAudit { - grant, + context, outcome, method: HttpMethod::Post, operation_id: "records.correction-request.create".to_owned(), @@ -1580,7 +1617,7 @@ mod delegated_actor_audit_tests { fn delegated_actor_is_recorded_as_a_keyed_pseudonym_with_its_kind() { let profile = profile(); let hasher = profile.key_hasher(); - let context = GrantAuditContext::from_claims(&claims(Some(ACTOR))) + let context = RequestAuditContext::from_claims(&claims(Some(ACTOR))) .expect("a verified trusted actor is retained for audit"); for (outcome, expected) in [ (TerminalAuditOutcome::Committed, "allowed"), @@ -1632,9 +1669,42 @@ mod delegated_actor_audit_tests { #[test] fn direct_token_without_grant_or_actor_records_no_authorization() { - assert!(GrantAuditContext::from_claims(&claims(None)).is_none()); - let record = terminal_record(terminal(None, TerminalAuditOutcome::Committed), &profile()) - .expect("direct terminal record"); + let record = terminal_record( + terminal( + RequestAuditContext::from_claims(&claims(None)), + TerminalAuditOutcome::Committed, + ), + &profile(), + ) + .expect("direct terminal record"); + assert_eq!( + record["clientReference"], + profile() + .key_hasher() + .audit_reference_hash("breg-client-v1", REVISION, "agent-client") + .unwrap() + ); assert!(!record.contains_key("authorization")); } + + #[test] + fn no_client_reference_when_the_verifier_did_not_match_a_client() { + let claims = claims(None) + .with_contextual_authority( + Some(registry_platform_oidc::ActorKind::Agent), + None, + None, + None, + BTreeMap::new(), + ) + .unwrap(); + let context = RequestAuditContext::from_claims(&claims); + assert!(context.is_none()); + let record = terminal_record( + terminal(context, TerminalAuditOutcome::Committed), + &profile(), + ) + .expect("terminal record"); + assert!(!record.contains_key("clientReference")); + } } diff --git a/crates/registry-breg/src/commit_guard.rs b/crates/registry-breg/src/commit_guard.rs index 6b74797bb1..6e81ac1e0c 100644 --- a/crates/registry-breg/src/commit_guard.rs +++ b/crates/registry-breg/src/commit_guard.rs @@ -44,13 +44,16 @@ pub(crate) fn guard_matches( actual.as_i64().is_some_and(|actual| actual <= maximum) } (None, None, None, None, Some(relation), None, None) => { - let Some(today) = registry_today(registry, now) else { + let Some(today) = registry_today(registry, now, guard.current_date_offset_days) else { return false; }; - actual.as_str().is_some_and(|date| match relation { - CompiledCurrentDateRelation::OnOrAfter => date >= today.as_str(), - CompiledCurrentDateRelation::OnOrBefore => date <= today.as_str(), - }) + actual + .as_str() + .and_then(|date| chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()) + .is_some_and(|date| match relation { + CompiledCurrentDateRelation::OnOrAfter => date >= today, + CompiledCurrentDateRelation::OnOrBefore => date <= today, + }) } (None, None, None, None, None, Some(input), None) => inputs .get(input) @@ -66,14 +69,28 @@ pub(crate) fn guard_matches( /// The registry's calendar date at `now`, the "today" a `currentDate` guard /// compares against. A date that cannot be resolved fails the guard closed. -fn registry_today(registry: &CompiledRegistry, now: time::OffsetDateTime) -> Option { - match registry.local_date_at(now) { - Ok(date) => Some(date.format("%Y-%m-%d").to_string()), - Err(_error) => { +fn registry_today( + registry: &CompiledRegistry, + now: time::OffsetDateTime, + offset_days: i32, +) -> Option { + let shifted = registry.local_date_at(now).ok().and_then(|date| { + if offset_days >= 0 { + date.checked_add_days(chrono::Days::new(offset_days as u64)) + } else { + date.checked_sub_days(chrono::Days::new(u64::from(offset_days.unsigned_abs()))) + } + }); + match shifted { + Some(date) => Some(date), + None => { // Only the runtime feature links `tracing`; test builds without it // still fail the guard closed. #[cfg(feature = "runtime")] - tracing::error!(error = %_error, "a currentDate guard could not resolve the registry's date and fails closed"); + tracing::error!( + offset_days, + "a currentDate guard could not resolve the registry's offset date and fails closed" + ); None } } @@ -180,6 +197,7 @@ mod tests { at_least: None, at_most: None, current_date: None, + current_date_offset_days: 0, at_least_input: None, at_most_input: None, } @@ -317,6 +335,58 @@ mod tests { )); } + #[test] + fn a_current_date_guard_applies_its_signed_day_offset() { + let inputs = Map::new(); + let mut guard = guard(); + guard.current_date = Some(CompiledCurrentDateRelation::OnOrAfter); + guard.current_date_offset_days = 2; + assert!(guard_matches( + &guard, + &inputs, + json!({"value":"2026-10-16"}).as_object().unwrap(), + ®istry(Some("UTC")), + noon(), + false + )); + assert!(!guard_matches( + &guard, + &inputs, + json!({"value":"2026-10-15"}).as_object().unwrap(), + ®istry(Some("UTC")), + noon(), + false + )); + + guard.current_date = Some(CompiledCurrentDateRelation::OnOrBefore); + guard.current_date_offset_days = -2; + assert!(guard_matches( + &guard, + &inputs, + json!({"value":"2026-10-12"}).as_object().unwrap(), + ®istry(Some("UTC")), + noon(), + false + )); + assert!(!guard_matches( + &guard, + &inputs, + json!({"value":"2026-10-13"}).as_object().unwrap(), + ®istry(Some("UTC")), + noon(), + false + )); + guard.current_date_offset_days = 1; + assert!(guard_matches( + &guard, + &inputs, + json!({"value":"9999-12-31"}).as_object().unwrap(), + ®istry(Some("UTC")), + datetime!(9999-12-31 12:00 UTC), + false + )); + } + fn ordered_guard_matches( at_least: bool, stored: Value, diff --git a/crates/registry-breg/src/compiler/timezone.rs b/crates/registry-breg/src/compiler/timezone.rs index 0b507f4e25..b7e5c0cc12 100644 --- a/crates/registry-breg/src/compiler/timezone.rs +++ b/crates/registry-breg/src/compiler/timezone.rs @@ -71,6 +71,16 @@ fn today_sites( } } for action in &actions.actions { + if action + .handler + .as_ref() + .is_some_and(|handler| handler.uses_today) + { + sites.push(format!( + "the Action `{}` plan function (`today()`)", + action.id + )); + } for requirement in action .requires .iter() diff --git a/crates/registry-breg/src/contract.rs b/crates/registry-breg/src/contract.rs index 01512f6d0f..036f45f9eb 100644 --- a/crates/registry-breg/src/contract.rs +++ b/crates/registry-breg/src/contract.rs @@ -2310,6 +2310,11 @@ pub struct ActionRequirementSource { pub at_most: Option, #[serde(default)] pub current_date: Option, + /// Whole calendar days added to the registry-local date before applying + /// `currentDate`. Zero preserves the unshifted comparison. + #[serde(default, skip_serializing_if = "crate::model::is_zero_i32")] + #[cfg_attr(feature = "schema", schemars(range(min = -365000, max = 365000)))] + pub current_date_offset_days: i32, /// Requires the stored value to be at or above the named required action /// input of the same date, instant, or integer type. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/registry-breg/src/immediate_actions.rs b/crates/registry-breg/src/immediate_actions.rs index 72ed06b236..6ecf4b5132 100644 --- a/crates/registry-breg/src/immediate_actions.rs +++ b/crates/registry-breg/src/immediate_actions.rs @@ -453,6 +453,7 @@ fn compile_handler( )); return None; } + let mut uses_today = false; // The authored source reference follows the declared backend exactly. let source_path: &str = if is_wasm { #[cfg(feature = "wasm")] @@ -833,6 +834,7 @@ fn compile_handler( )); return None; }; + uses_today = crate::action_handler::source_uses_today(script, &function.entrypoint); if let Err(error) = crate::rhai_planner::compile_entrypoint_detailed(script, &function.entrypoint) { @@ -973,6 +975,7 @@ fn compile_handler( .then(|| format!("sha256:{}", hex_lower(&Sha256::digest(&asset.bytes)))), module_sha256: is_wasm .then(|| format!("sha256:{}", hex_lower(&Sha256::digest(&asset.bytes)))), + uses_today, script_bytes: if is_wasm { Vec::new() } else { @@ -1116,6 +1119,7 @@ fn compile_requirements( || requirement.at_least.is_some() || requirement.at_most.is_some() || requirement.current_date.is_some() + || requirement.current_date_offset_days != 0 || requirement.at_least_from_input.is_some() || requirement.at_most_from_input.is_some() { @@ -1136,6 +1140,7 @@ fn compile_requirements( at_least: None, at_most: None, current_date: None, + current_date_offset_days: 0, at_least_input: None, at_most_input: None, }); @@ -1169,6 +1174,17 @@ fn compile_requirements( + usize::from(requirement.current_date.is_some()) + usize::from(requirement.at_least_from_input.is_some()) + usize::from(requirement.at_most_from_input.is_some()); + if requirement.current_date_offset_days != 0 + && (requirement.current_date.is_none() + || !(-365_000..=365_000).contains(&requirement.current_date_offset_days)) + { + errors.push(Diagnostic::error( + "breg.action.commit_guards.current-date-offset-invalid", + "actions[].commitGuards.targets[].currentDateOffsetDays", + "currentDateOffsetDays requires currentDate and must be from -365000 through 365000 whole days", + )); + continue; + } let literal = requirement.literal(); let literal_invalid = literal.as_ref().is_some_and(|equals| { equals.is_array() @@ -1241,6 +1257,7 @@ fn compile_requirements( crate::model::CompiledCurrentDateRelation::OnOrBefore } }), + current_date_offset_days: requirement.current_date_offset_days, at_least_input: requirement.at_least_from_input.clone(), at_most_input: requirement.at_most_from_input.clone(), }); diff --git a/crates/registry-breg/src/model.rs b/crates/registry-breg/src/model.rs index 0666a7763b..22947bf3c4 100644 --- a/crates/registry-breg/src/model.rs +++ b/crates/registry-breg/src/model.rs @@ -20,6 +20,10 @@ use crate::diagnostics::Diagnostic; use crate::generated_ddl::DdlInventory; use crate::physical_names::PhysicalNameInventory; +pub(crate) fn is_zero_i32(value: &i32) -> bool { + *value == 0 +} + #[cfg(feature = "runtime")] pub(crate) const MAX_TARGET_CONTEXT_FIELDS: usize = 128; @@ -578,6 +582,8 @@ pub struct CompiledCommitGuard { pub at_least: Option, pub at_most: Option, pub current_date: Option, + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub current_date_offset_days: i32, /// Keeps the stored field at or above the named action input. #[serde(default, skip_serializing_if = "Option::is_none")] pub at_least_input: Option, @@ -623,6 +629,9 @@ pub struct CompiledPlanFunction { pub script_sha256: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub module_sha256: Option, + /// The reviewed Rhai source calls the host-provided `today()` helper. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub uses_today: bool, #[serde(skip)] pub script_bytes: Vec, #[serde(skip)] diff --git a/crates/registry-breg/src/mutation.rs b/crates/registry-breg/src/mutation.rs index 4eb833df73..4970a1b831 100644 --- a/crates/registry-breg/src/mutation.rs +++ b/crates/registry-breg/src/mutation.rs @@ -1209,7 +1209,7 @@ impl MutationTransaction { let entry = terminal_entry( self.audit.profile(), TerminalAudit { - grant: request.claims.grant_audit().cloned(), + context: request.claims.audit_context().cloned(), outcome: TerminalAuditOutcome::Replayed, method: request.plan.route.method, operation_id: request.plan.route.id.clone(), @@ -1369,7 +1369,7 @@ impl MutationTransaction { let entry = terminal_entry( self.audit.profile(), TerminalAudit { - grant: request.claims.grant_audit().cloned(), + context: request.claims.audit_context().cloned(), outcome: TerminalAuditOutcome::Committed, method: request.plan.route.method, operation_id: request.plan.route.id.clone(), @@ -1788,7 +1788,7 @@ impl MutationTransaction { let entry = terminal_entry( self.audit.profile(), TerminalAudit { - grant: request.claims.grant_audit().cloned(), + context: request.claims.audit_context().cloned(), outcome: TerminalAuditOutcome::Replayed, method: request.plan.route.method, operation_id: request.plan.route.id.clone(), @@ -2003,7 +2003,7 @@ impl MutationTransaction { let entry = terminal_entry( self.audit.profile(), TerminalAudit { - grant: request.claims.grant_audit().cloned(), + context: request.claims.audit_context().cloned(), outcome: TerminalAuditOutcome::Committed, method: request.plan.route.method, operation_id: request.plan.route.id.clone(), diff --git a/crates/registry-breg/src/mutation/action.rs b/crates/registry-breg/src/mutation/action.rs index 4f07d7b09f..bb8b514666 100644 --- a/crates/registry-breg/src/mutation/action.rs +++ b/crates/registry-breg/src/mutation/action.rs @@ -9,7 +9,9 @@ use registry_platform_hooks::{ use crate::action_evidence::FrozenEvidenceEvaluation; use crate::action_evidence_client::EvidenceSubjects; use crate::action_evidence_contracts::CompiledEvidenceCapability; -use crate::action_handler::{evaluate_admitted_action_detailed, ActionHandlerOutcome}; +use crate::action_handler::{ + evaluate_admitted_action_detailed_with_date, trusted_evaluation_date, ActionHandlerOutcome, +}; use crate::action_outcome::{ bound_document, decode_document, json_document, validate_proposed_outcome, }; @@ -241,6 +243,7 @@ impl MutationTransaction { } }; let mut candidate = None; + let trusted_now = time::OffsetDateTime::now_utc(); let result = self .execute_immediate_action_after_attempt( client, @@ -257,6 +260,7 @@ impl MutationTransaction { execution.correlation, fault, deadline, + trusted_now, &mut candidate, None, None, @@ -589,6 +593,7 @@ impl MutationTransaction { )?; let reserved_creates = reserve_action_create_ids(action)?; let mut candidate = None; + let trusted_now = time::OffsetDateTime::now_utc(); let application_id = Uuid::new_v4(); let mut retryable_attempts = 0; let result = loop { @@ -608,6 +613,7 @@ impl MutationTransaction { input.correlation, fault, deadline, + trusted_now, &mut candidate, None, None, @@ -1172,6 +1178,7 @@ impl MutationTransaction { let deadline = tokio::time::Instant::now() + HOOK_PROPOSAL_APPLY_BUDGET; let fault = FaultControl::Disabled; let mut candidate = Some(outcome_effects); + let trusted_now = time::OffsetDateTime::now_utc(); let mut retryable_attempts = 0; let result = loop { let attempt = self @@ -1190,6 +1197,7 @@ impl MutationTransaction { &correlation, fault, deadline, + trusted_now, &mut candidate, None, None, @@ -1387,6 +1395,7 @@ impl MutationTransaction { correlation: &RequestCorrelation, fault: FaultControl, deadline: tokio::time::Instant, + trusted_now: time::OffsetDateTime, candidate: &mut Option>, frozen: Option<&FrozenEvidenceEvaluation>, commit_requests: Option<&[(CompiledEvidenceCapability, EvidenceSubjects)]>, @@ -1485,6 +1494,7 @@ impl MutationTransaction { application_id, preconditions, timer_authority, + trusted_now, ) .await?; let entity = registry @@ -1562,10 +1572,13 @@ impl MutationTransaction { // holding database authority or committing late effects. let compiled_action = action.clone(); let handler_inputs = input_values.clone(); + let evaluation_date = trusted_evaluation_date(action, registry, trusted_now) + .map_err(MutationError::ActionHandlerFailure)?; let outcome = tokio::task::spawn_blocking(move || { - evaluate_admitted_action_detailed( + evaluate_admitted_action_detailed_with_date( &compiled_action, &handler_inputs, + evaluation_date, deadline.into_std(), ) }) @@ -1618,6 +1631,7 @@ impl MutationTransaction { application_id, preconditions, timer_authority, + trusted_now, ) .await? .guard_failure @@ -1892,7 +1906,7 @@ impl MutationTransaction { let entry = action_terminal_entry( self.audit.profile(), TerminalAudit { - grant: claims.grant_audit().cloned(), + context: claims.audit_context().cloned(), outcome: TerminalAuditOutcome::Committed, method: HttpMethod::Post, operation_id: route_id.to_owned(), @@ -2025,7 +2039,7 @@ impl MutationTransaction { let entry = action_terminal_entry( self.audit.profile(), TerminalAudit { - grant: claims.grant_audit().cloned(), + context: claims.audit_context().cloned(), outcome: TerminalAuditOutcome::Replayed, method: HttpMethod::Post, operation_id: route_id.to_owned(), @@ -2194,6 +2208,7 @@ impl MutationTransaction { application_id: Uuid, preconditions: &BTreeMap, timer_authority: Option<&TimerActionAuthority>, + trusted_now: time::OffsetDateTime, ) -> Result { #[derive(Clone, Copy)] enum Role<'a> { @@ -2381,7 +2396,7 @@ impl MutationTransaction { ¤t, self.audit.profile(), registry, - time::OffsetDateTime::now_utc(), + trusted_now, preconditions, &mut guard_failure, )?; @@ -2626,7 +2641,7 @@ impl MutationTransaction { let entry = terminal_entry( self.audit.profile(), TerminalAudit { - grant: claims.grant_audit().cloned(), + context: claims.audit_context().cloned(), outcome: TerminalAuditOutcome::Returned, method: HttpMethod::Post, operation_id: route_id.to_owned(), @@ -3657,6 +3672,7 @@ pub(crate) struct PreparedEvidenceAction { binding: crate::idempotency::ResolvedIdempotencyBinding, reserved_creates: BTreeMap, application_id: Uuid, + pub(crate) trusted_now: time::OffsetDateTime, /// The action's attempt, held across Evidence evaluation until the /// finalize terminal or refusal answers it. Dropping the admission /// before that answers the attempt as unfinished. @@ -3873,6 +3889,7 @@ impl MutationTransaction { check_current_action_grant(claims)?; return Ok(Err(outcome)); } + let trusted_now = time::OffsetDateTime::now_utc(); // This transaction proves current admission only. Its locks and entire // pooled connection are released before any Evidence request. let guard_failure = self @@ -3886,6 +3903,7 @@ impl MutationTransaction { application_id, &input.preconditions, None, + trusted_now, ) .await? .guard_failure; @@ -3939,6 +3957,7 @@ impl MutationTransaction { binding, reserved_creates: reserve_action_create_ids(action)?, application_id, + trusted_now, attempt: None, })) } @@ -3959,6 +3978,7 @@ impl MutationTransaction { ) -> Result { validate_action_claims(&prepared.action, claims, Operation::Invoke)?; let mut candidate = None; + let trusted_now = prepared.trusted_now; let mut retries = 0; let result = loop { let result = self @@ -3977,6 +3997,7 @@ impl MutationTransaction { correlation, fault, deadline, + trusted_now, &mut candidate, Some(frozen), Some(&prepared.commit_requests), diff --git a/crates/registry-breg/src/mutation/proposal.rs b/crates/registry-breg/src/mutation/proposal.rs index 1f67a60bf1..2ec2e2b427 100644 --- a/crates/registry-breg/src/mutation/proposal.rs +++ b/crates/registry-breg/src/mutation/proposal.rs @@ -458,7 +458,7 @@ impl MutationTransaction { let entry = crate::audit::terminal_entry( self.audit.profile(), TerminalAudit { - grant: authority.common.grant_audit().cloned(), + context: authority.common.audit_context().cloned(), outcome: if count == 0 { TerminalAuditOutcome::Empty } else { @@ -1258,9 +1258,17 @@ impl MutationTransaction { .ok_or(MutationError::PreconditionFailed)?; proposal_prepare::validate_inputs(action, values)?; let values = values.clone(); + let trusted_now = time::OffsetDateTime::now_utc(); + let evaluation_date = crate::action_handler::trusted_evaluation_date( + action, + registry, + trusted_now, + ) + .map_err(MutationError::ActionHandlerFailure)?; let candidate = crate::action_handler::evaluate_plan_detailed( action, &values, + evaluation_date, deadline.into_std(), ) .map_err(|diagnostic| { @@ -1299,6 +1307,7 @@ impl MutationTransaction { input.authority, &resolved, &values, + trusted_now, ) .await?; // The exact-command replay lookup ran before plan evaluation. @@ -2019,7 +2028,7 @@ impl MutationTransaction { changed_inputs: &[String], ) -> Result { let terminal = TerminalAudit { - grant: input.authority.common.grant_audit().cloned(), + context: input.authority.common.audit_context().cloned(), outcome, method: HttpMethod::Post, operation_id: format!( @@ -2139,6 +2148,7 @@ impl MutationTransaction { authority: &ProposalAuthority, resolved: &proposal_prepare::ResolvedProposalTargets, input: &Map, + trusted_now: time::OffsetDateTime, ) -> Result< ( BTreeMap<(String, Uuid), (i64, Map)>, @@ -2226,7 +2236,7 @@ impl MutationTransaction { revision, Some(revision), registry, - time::OffsetDateTime::now_utc(), + trusted_now, )?; guards.insert(guard.input.clone(), (id, revision, row)); } @@ -2329,6 +2339,7 @@ impl MutationTransaction { authority: &ProposalAuthority, frozen: &crate::proposal_workflow::ProposalSnapshot, ) -> Result<(), MutationError> { + let trusted_now = time::OffsetDateTime::now_utc(); let Some(preconditions) = frozen.application_preconditions() else { return if action.requires.is_empty() { Ok(()) @@ -2394,7 +2405,7 @@ impl MutationTransaction { row.record_revision, Some(target.expected_revision), registry, - time::OffsetDateTime::now_utc(), + trusted_now, )?; } if let Some(fields) = @@ -4547,6 +4558,7 @@ mod commit_guard_date_tests { at_least: None, at_most: None, current_date: Some(crate::model::CompiledCurrentDateRelation::OnOrAfter), + current_date_offset_days: 0, at_least_input: None, at_most_input: None, }; diff --git a/crates/registry-breg/src/postgres/context.rs b/crates/registry-breg/src/postgres/context.rs index 411e42a116..bd09334521 100644 --- a/crates/registry-breg/src/postgres/context.rs +++ b/crates/registry-breg/src/postgres/context.rs @@ -57,7 +57,7 @@ pub(crate) struct ActionClaimContext { access_profile: String, purpose: Option, result_effects: BTreeSet, - grant_audit: Option, + audit_context: Option, task_grant: Option, } @@ -75,22 +75,22 @@ impl ActionClaimContext { access_profile, purpose, result_effects, - grant_audit: None, + audit_context: None, task_grant: None, }; context.validate()?; Ok(context) } - pub(crate) fn with_grant_audit( + pub(crate) fn with_audit_context( mut self, - grant: Option, + grant: Option, ) -> Self { - self.grant_audit = grant; + self.audit_context = grant; self } - pub(crate) fn grant_audit(&self) -> Option<&crate::audit::GrantAuditContext> { - self.grant_audit.as_ref() + pub(crate) fn audit_context(&self) -> Option<&crate::audit::RequestAuditContext> { + self.audit_context.as_ref() } pub(crate) fn with_task_grant( @@ -289,7 +289,7 @@ pub struct ClaimContext { row_boundaries: Vec, canonical_row_boundaries: String, task_grant: Option, - grant_audit: Option, + audit_context: Option, human_identity: Option, recipients: BTreeSet, } @@ -317,15 +317,15 @@ impl SpatialBboxContext { } impl ClaimContext { - pub(crate) fn with_grant_audit( + pub(crate) fn with_audit_context( mut self, - grant: Option, + grant: Option, ) -> Self { - self.grant_audit = grant; + self.audit_context = grant; self } - pub(crate) fn grant_audit(&self) -> Option<&crate::audit::GrantAuditContext> { - self.grant_audit.as_ref() + pub(crate) fn audit_context(&self) -> Option<&crate::audit::RequestAuditContext> { + self.audit_context.as_ref() } pub(crate) fn with_human_identity( @@ -438,7 +438,7 @@ impl ClaimContext { row_boundaries, canonical_row_boundaries, task_grant: None, - grant_audit: None, + audit_context: None, human_identity: None, recipients: BTreeSet::new(), }) @@ -472,7 +472,7 @@ impl ClaimContext { row_boundaries, canonical_row_boundaries, task_grant: None, - grant_audit: None, + audit_context: None, human_identity: None, recipients: BTreeSet::new(), }) diff --git a/crates/registry-breg/src/postgres/history_read.rs b/crates/registry-breg/src/postgres/history_read.rs index 5bff100673..d540afcb09 100644 --- a/crates/registry-breg/src/postgres/history_read.rs +++ b/crates/registry-breg/src/postgres/history_read.rs @@ -501,7 +501,7 @@ impl PostgresSnapshotReadService { self.audit.profile(), ReadTerminalAudit { terminal: TerminalAudit { - grant: claims.grant_audit().cloned(), + context: claims.audit_context().cloned(), outcome, method: request.method, operation_id: request.operation_id.clone(), @@ -544,7 +544,10 @@ impl SnapshotReadService for PostgresSnapshotReadService { &self.expected, crate::audit::HttpRefusalAudit { refusal_reason: request.refusal_reason, - grant: None, + context: request + .requester_client + .as_deref() + .map(crate::audit::RequestAuditContext::client_only), method: request.method.into(), operation_id: &request.operation_id, target_record: request.target_record.as_deref(), @@ -1565,7 +1568,7 @@ fn strict_claim_context( context.purpose().map(str::to_owned), row_boundaries, ) - .map(|claims| claims.with_grant_audit(context.grant_audit().cloned())) + .map(|claims| claims.with_audit_context(context.audit_context().cloned())) .and_then(|claims| claims.with_recipients(context.recipients().clone())) .map_err(|_| ReadServiceError::Unavailable) } diff --git a/crates/registry-breg/src/postgres/mutation.rs b/crates/registry-breg/src/postgres/mutation.rs index 0dcab6639f..a84ff8d147 100644 --- a/crates/registry-breg/src/postgres/mutation.rs +++ b/crates/registry-breg/src/postgres/mutation.rs @@ -490,10 +490,17 @@ impl PostgresRecordMutationService { }; // Drop the entire pooled connection before evaluation or acquisition. let mut frozen = match self.evidence_evaluator.as_ref() { Some(evaluator) if prepared.action.handler.is_some() => { + let evaluation_date = crate::action_handler::trusted_evaluation_date( + &prepared.action, + &self.registry, + prepared.trusted_now, + ) + .map_err(MutationError::ActionHandlerFailure)?; evaluator .evaluate( prepared.action.clone(), prepared.inputs.clone(), + evaluation_date, deadline.into_std(), ) .await @@ -2373,7 +2380,7 @@ fn strict_claim_context( context.purpose().map(str::to_owned), row_boundaries, ) - .map(|claims| claims.with_grant_audit(context.grant_audit().cloned())) + .map(|claims| claims.with_audit_context(context.audit_context().cloned())) .and_then(|claims| claims.with_human_identity(context.human_identity().cloned())) .and_then(|claims| claims.with_recipients(context.recipients().clone())) .and_then(|claims| match context.task_grant() { @@ -2397,7 +2404,7 @@ fn strict_action_context( context.purpose().map(str::to_owned), context.result_effects().clone(), ) - .map(|claims| claims.with_grant_audit(context.grant_audit().cloned())) + .map(|claims| claims.with_audit_context(context.audit_context().cloned())) .and_then(|claims| match context.task_grant() { Some(binding) => claims.with_task_grant(binding.clone()), None => Ok(claims), diff --git a/crates/registry-breg/src/postgres/read.rs b/crates/registry-breg/src/postgres/read.rs index 877dfc77a0..af845ec74c 100644 --- a/crates/registry-breg/src/postgres/read.rs +++ b/crates/registry-breg/src/postgres/read.rs @@ -642,7 +642,7 @@ impl PostgresRecordReadService { &request.selected_fields, )?; Ok(TerminalAudit { - grant: claims.grant_audit().cloned(), + context: claims.audit_context().cloned(), outcome, method: request.method, operation_id: request.operation_id.clone(), @@ -734,7 +734,10 @@ impl RecordReadService for PostgresRecordReadService { &self.expected, crate::audit::HttpRefusalAudit { refusal_reason: request.refusal_reason, - grant: None, + context: request + .requester_client + .as_deref() + .map(crate::audit::RequestAuditContext::client_only), method: request.method.into(), operation_id: &request.operation_id, target_record: request.target_record.as_deref(), @@ -1895,7 +1898,7 @@ pub(crate) fn strict_claim_context( context.purpose().map(str::to_owned), row_boundaries, ) - .map(|claims| claims.with_grant_audit(context.grant_audit().cloned())) + .map(|claims| claims.with_audit_context(context.audit_context().cloned())) .and_then(|claims| claims.with_recipients(context.recipients().clone())) .map_err(|_| ReadServiceError::Unavailable) } diff --git a/crates/registry-breg/src/postgres/revision_read.rs b/crates/registry-breg/src/postgres/revision_read.rs index d1a606b256..2b8623abd0 100644 --- a/crates/registry-breg/src/postgres/revision_read.rs +++ b/crates/registry-breg/src/postgres/revision_read.rs @@ -351,7 +351,7 @@ impl PostgresRevisionReadService { self.audit.profile(), ReadTerminalAudit { terminal: TerminalAudit { - grant: claims.grant_audit().cloned(), + context: claims.audit_context().cloned(), outcome, method: request.method, operation_id: request.operation_id.clone(), @@ -401,7 +401,10 @@ impl RevisionReadService for PostgresRevisionReadService { &self.expected, crate::audit::HttpRefusalAudit { refusal_reason: request.refusal_reason, - grant: None, + context: request + .requester_client + .as_deref() + .map(crate::audit::RequestAuditContext::client_only), method: request.method.into(), operation_id: &request.operation_id, target_record: request.target_record.as_deref(), @@ -1267,7 +1270,7 @@ fn strict_claim_context( context.purpose().map(str::to_owned), row_boundaries, ) - .map(|claims| claims.with_grant_audit(context.grant_audit().cloned())) + .map(|claims| claims.with_audit_context(context.audit_context().cloned())) .and_then(|claims| claims.with_recipients(context.recipients().clone())) .map_err(|_| ReadServiceError::Unavailable) } diff --git a/crates/registry-breg/src/proposal_prepare/tests.rs b/crates/registry-breg/src/proposal_prepare/tests.rs index a89ab9802e..21173c345a 100644 --- a/crates/registry-breg/src/proposal_prepare/tests.rs +++ b/crates/registry-breg/src/proposal_prepare/tests.rs @@ -82,6 +82,7 @@ fn candidate( match evaluate_plan_detailed( action(registry), input, + None, Instant::now() + Duration::from_secs(1), ) .map_err(|_| MutationError::InvalidRequest)? @@ -306,6 +307,69 @@ fn fixed_and_rhai_plans_produce_byte_equivalent_canonical_effects() { assert_eq!(a.after, b.after); } +#[test] +fn proposal_preparation_freezes_the_date_computed_at_submission() { + let mut source = function_source(); + source["project"]["timezone"] = json!("UTC"); + let script = r#"fn plan(ctx) { #{effects:[#{id:"patch-first",set:#{first:today()}}]} }"#; + let registry = compile(source, Some(script)); + let input = map(json!({"one":TARGET,"value":"ignored"})); + let effects = match evaluate_plan_detailed( + action(®istry), + &input, + Some(time::macros::date!(2026 - 01 - 31)), + Instant::now() + Duration::from_secs(1), + ) + .unwrap() + { + ActionHandlerOutcome::Effects(effects) => effects, + ActionHandlerOutcome::Refusal(_) => panic!("effects expected"), + }; + let resolved = resolve_targets( + ®istry, + action(®istry), + &input, + effects, + &BTreeMap::new(), + ) + .unwrap(); + let prepared = prepare( + ®istry, + action(®istry), + &input, + 1, + ACTIVATION_ID, + &resolved, + BTreeMap::from([( + ("target".into(), Uuid::parse_str(TARGET).unwrap()), + (7, map(json!({"first":"old"}))), + )]), + BTreeMap::new(), + ) + .unwrap(); + assert_eq!(prepared.targets[0].after["first"], json!("2026-01-31")); + + let later = evaluate_plan_detailed( + action(®istry), + &input, + Some(time::macros::date!(2026 - 02 - 01)), + Instant::now() + Duration::from_secs(1), + ) + .unwrap(); + let ActionHandlerOutcome::Effects(later) = later else { + panic!("effects expected") + }; + assert_ne!( + serde_json::to_value(prepared.proposal.effects()).unwrap(), + serde_json::to_value(later).unwrap() + ); + assert_eq!( + prepared.targets[0].after["first"], + json!("2026-01-31"), + "the retained proposal plan is not recomputed for replay or Apply" + ); +} + #[test] fn unchanged_snapshot_bytes_count_toward_the_full_packet_limit() { let registry = fixture(json!([patch_effect()])); diff --git a/crates/registry-breg/src/proposal_retention.rs b/crates/registry-breg/src/proposal_retention.rs index 88e6286b90..ee4d2b1f96 100644 --- a/crates/registry-breg/src/proposal_retention.rs +++ b/crates/registry-breg/src/proposal_retention.rs @@ -1596,7 +1596,7 @@ fn retention_terminal_entry( terminal_entry( profile, TerminalAudit { - grant: None, + context: None, outcome: TerminalAuditOutcome::Committed, method: HttpMethod::Delete, operation_id: RETENTION_OPERATION_ID.to_owned(), diff --git a/crates/registry-breg/src/proposal_workflow.rs b/crates/registry-breg/src/proposal_workflow.rs index eec4b505e8..4a90a3d2a7 100644 --- a/crates/registry-breg/src/proposal_workflow.rs +++ b/crates/registry-breg/src/proposal_workflow.rs @@ -2827,6 +2827,7 @@ mod source_owned_review_tests { at_least: None, at_most: None, current_date: Some(crate::model::CompiledCurrentDateRelation::OnOrAfter), + current_date_offset_days: 0, at_least_input: None, at_most_input: None, }], diff --git a/crates/registry-breg/src/rhai_literal_lint.rs b/crates/registry-breg/src/rhai_literal_lint.rs index 30b1d2a498..3de9768535 100644 --- a/crates/registry-breg/src/rhai_literal_lint.rs +++ b/crates/registry-breg/src/rhai_literal_lint.rs @@ -94,7 +94,8 @@ pub(crate) struct UnavailableCall { /// defines a function of that name itself. The left-out names are derived /// from the two engines rather than listed, so they follow the plan engine. pub(crate) fn unavailable_calls(script: &str) -> Vec { - let mut engine = crate::rhai_planner::engine(None); + let mut engine = + crate::rhai_planner::engine_with_date(None, Some(time::macros::date!(2000 - 01 - 01))); // As above: inspect the calls as authored, not as the optimizer rewrote // them. engine.set_optimization_level(rhai::OptimizationLevel::None); @@ -113,22 +114,19 @@ pub(crate) fn unavailable_calls(script: &str) -> Vec { .collect::>(); let mut calls = Vec::new(); ast.walk(&mut |path| { - let named = match path.last() { + let named = literal_function_pointer(path).or_else(|| match path.last() { Some(ASTNode::Expr(Expr::FnCall(call, position))) if !call.is_qualified() => { - if call.name == "Fn" && call.args.len() == 1 { - literal_string(&call.args[0]).map(|name| (name, *position)) - } else { + if !(call.name == "Fn" && call.args.len() == 1) { Some((call.name.to_string(), *position)) + } else { + None } } Some(ASTNode::Expr(Expr::MethodCall(call, position))) => { Some((call.name.to_string(), *position)) } - Some(ASTNode::Expr(Expr::DynamicConstant(value, position))) => value - .read_lock::() - .map(|pointer| (pointer.fn_name().to_owned(), *position)), _ => None, - }; + }); if let Some((function, position)) = named { if unavailable.contains(&function) { calls.push(UnavailableCall { function, position }); @@ -139,6 +137,23 @@ pub(crate) fn unavailable_calls(script: &str) -> Vec { calls } +/// A function pointer named by a literal `Fn("name")` call, or by the +/// optimizer's equivalent constant. Both authoring checks must recognize the +/// same pointer spelling regardless of optimization level. +pub(crate) fn literal_function_pointer(path: &[ASTNode<'_>]) -> Option<(String, rhai::Position)> { + match path.last() { + Some(ASTNode::Expr(Expr::FnCall(call, position))) + if !call.is_qualified() && call.name == "Fn" && call.args.len() == 1 => + { + literal_string(&call.args[0]).map(|name| (name, *position)) + } + Some(ASTNode::Expr(Expr::DynamicConstant(value, position))) => value + .read_lock::() + .map(|pointer| (pointer.fn_name().to_owned(), *position)), + _ => None, + } +} + fn function_names(engine: &Engine) -> BTreeSet { engine .collect_fn_metadata(None, |info| Some(info.metadata.name.to_string()), true) diff --git a/crates/registry-breg/src/rhai_planner.rs b/crates/registry-breg/src/rhai_planner.rs index 1342a67528..ad4faba2ce 100644 --- a/crates/registry-breg/src/rhai_planner.rs +++ b/crates/registry-breg/src/rhai_planner.rs @@ -3,9 +3,10 @@ //! execution mechanics; BReg owns inputs and plan validation. use registry_platform_script::rhai as platform; -use rhai::{Array, Dynamic, Engine, EvalAltResult, ImmutableString, Map, Position, AST}; +use rhai::{Array, Dynamic, Engine, EvalAltResult, ImmutableString, Map, Position, AST, INT}; use serde_json::Value; use std::time::Instant; +use time::{Date, Duration, Month}; pub const MAXIMUM_OPERATIONS: u64 = 100_000; pub const MAXIMUM_CALL_DEPTH: usize = 32; @@ -99,8 +100,12 @@ pub(crate) fn compile_entrypoint_detailed( } pub(crate) fn engine(deadline: Option) -> Engine { - platform::build_engine(&SCRIPT_PROFILE, deadline, |engine| { - engine.register_fn("join", join_strings); + engine_with_date(deadline, None) +} + +pub(crate) fn engine_with_date(deadline: Option, evaluation_date: Option) -> Engine { + platform::build_engine(&SCRIPT_PROFILE, deadline, move |engine| { + register_action_helpers(engine, evaluation_date); }) } @@ -108,9 +113,10 @@ pub(crate) fn engine(deadline: Option) -> Engine { pub(crate) fn function_engine( deadline: Instant, limits: Option<&crate::model::CompiledPlanFunctionLimits>, + evaluation_date: Option, ) -> Engine { let Some(limits) = limits else { - return engine(Some(deadline)); + return engine_with_date(Some(deadline), evaluation_date); }; let profile = platform::RhaiProfile { limits: platform::RhaiLimits { @@ -126,11 +132,91 @@ pub(crate) fn function_engine( maximum_source_bytes: (limits.maximum_source_bytes as usize).min(MAXIMUM_SOURCE_BYTES), ..SCRIPT_PROFILE }; - platform::build_engine(&profile, Some(deadline), |engine| { - engine.register_fn("join", join_strings); + platform::build_engine(&profile, Some(deadline), move |engine| { + register_action_helpers(engine, evaluation_date); }) } +fn register_action_helpers(engine: &mut Engine, evaluation_date: Option) { + engine + .register_fn("join", join_strings) + .register_fn("add_days", add_days) + .register_fn("add_months", add_months); + if let Some(evaluation_date) = evaluation_date { + engine.register_fn("today", move || format_date(evaluation_date)); + } +} + +fn helper_error() -> Box { + EvalAltResult::ErrorRuntime("invalid date calculation".into(), Position::NONE).into() +} + +fn parse_date(value: &str) -> Result> { + if value.len() != 10 + || value.as_bytes().get(4) != Some(&b'-') + || value.as_bytes().get(7) != Some(&b'-') + || value + .bytes() + .enumerate() + .any(|(index, byte)| !matches!(index, 4 | 7) && !byte.is_ascii_digit()) + { + return Err(helper_error()); + } + let year = value[0..4].parse::().map_err(|_| helper_error())?; + let month = value[5..7] + .parse::() + .ok() + .and_then(|month| Month::try_from(month).ok()) + .ok_or_else(helper_error)?; + let day = value[8..10].parse::().map_err(|_| helper_error())?; + if !(1..=9999).contains(&year) { + return Err(helper_error()); + } + Date::from_calendar_date(year, month, day).map_err(|_| helper_error()) +} + +fn format_date(value: Date) -> ImmutableString { + value + .format(time::macros::format_description!("[year]-[month]-[day]")) + .expect("a Date always formats as an ISO calendar date") + .into() +} + +fn add_days(value: ImmutableString, days: INT) -> Result> { + if !(-365_000..=365_000).contains(&days) { + return Err(helper_error()); + } + parse_date(value.as_str())? + .checked_add(Duration::days(days)) + .filter(|date| (1..=9999).contains(&date.year())) + .map(format_date) + .ok_or_else(helper_error) +} + +fn add_months(value: ImmutableString, months: INT) -> Result> { + if !(-12_000..=12_000).contains(&months) { + return Err(helper_error()); + } + let date = parse_date(value.as_str())?; + let month_index = i64::from(date.year()) + .checked_mul(12) + .and_then(|index| index.checked_add(i64::from(u8::from(date.month())) - 1)) + .and_then(|index| index.checked_add(months)) + .ok_or_else(helper_error)?; + let year = i32::try_from(month_index.div_euclid(12)).map_err(|_| helper_error())?; + if !(1..=9999).contains(&year) { + return Err(helper_error()); + } + let month = u8::try_from(month_index.rem_euclid(12) + 1) + .ok() + .and_then(|month| Month::try_from(month).ok()) + .ok_or_else(helper_error)?; + let day = date.day().min(time::util::days_in_month(month, year)); + Date::from_calendar_date(year, month, day) + .map(format_date) + .map_err(|_| helper_error()) +} + fn join_strings( values: &mut Array, separator: ImmutableString, diff --git a/crates/registry-breg/src/tests/wasm_runtime_tests.rs b/crates/registry-breg/src/tests/wasm_runtime_tests.rs index 613e4011f6..a5b551920d 100644 --- a/crates/registry-breg/src/tests/wasm_runtime_tests.rs +++ b/crates/registry-breg/src/tests/wasm_runtime_tests.rs @@ -303,6 +303,7 @@ fn cache_handler_fixture(document: &str) -> CompiledPlanFunction { rhai_version: None, script_sha256: None, module_sha256: Some(format!("sha256:{hex}")), + uses_today: false, script_bytes: Vec::new(), module_bytes: bytes, limits: None, diff --git a/crates/registry-breg/tests/immediate_action_handler.rs b/crates/registry-breg/tests/immediate_action_handler.rs index cb068bfa26..ff58be3556 100644 --- a/crates/registry-breg/tests/immediate_action_handler.rs +++ b/crates/registry-breg/tests/immediate_action_handler.rs @@ -2,8 +2,8 @@ use registry_breg::{ action_handler::{ - compile_source, evaluate_action, evaluate_action_detailed, ActionHandlerError, - ActionHandlerOutcome, + compile_source, evaluate_action, evaluate_action_at_date, evaluate_action_detailed, + ActionHandlerError, ActionHandlerOutcome, }, compiler::{compile_project_with_assets, CompileProfile}, contract::{parse_project_json, ActionPlanSource, ModuleAssetSource}, @@ -11,6 +11,7 @@ use registry_breg::{ }; use serde_json::{json, Value}; use std::time::{Duration, Instant}; +use time::macros::date; fn project() -> Value { json!({"datasets":[{"id": "test-dataset"}],"functions":[{"id":"register-person-plan","runtime":"rhai","source":"handlers/register.rhai","entrypoint":"handle","abi":"registry.action-plan/v1"}], @@ -160,6 +161,67 @@ fn handler_computes_from_authored_input_ids_and_omits_slots() { .any(|target| target.condition_required)); } +#[test] +fn date_helpers_use_one_trusted_date_and_return_date_strings() { + let mut date_project = project(); + date_project["project"]["timezone"] = json!("UTC"); + let action = compile( + date_project, + r#"fn handle(ctx) { + let current_date = Fn("today"); + #{effects:[#{id:"person",set:#{name:today() + ":" + current_date.call() + ":" + add_days(today(), 2) + ":" + add_months("2024-01-31", 1)}}]} + }"#, + ) + .expect("the date helper plan compiles"); + let ActionHandlerOutcome::Effects(effects) = evaluate_action_at_date( + &action, + inputs().as_object().unwrap(), + date!(2026 - 10 - 14), + Instant::now() + Duration::from_secs(5), + ) + .expect("date helpers evaluate") else { + panic!("effects expected") + }; + assert!(effects[0].mutations.contains(&CompiledPlanMutation::Set { + field: "name".into(), + value: CompiledPlanValue::Literal { + value: json!("2026-10-14:2026-10-14:2026-10-16:2024-02-29") + } + })); +} + +#[test] +fn date_helpers_refuse_invalid_dates_results_and_unbounded_offsets() { + for expression in [ + r#"add_days("2026-02-29", 1)"#, + r#"add_days("9999-12-31", 1)"#, + r#"add_days("2026-01-01", 365001)"#, + r#"add_months("2026-01-01", 12001)"#, + r#"add_months("0001-01-01", -1)"#, + ] { + let mut date_project = project(); + date_project["project"]["timezone"] = json!("UTC"); + let action = compile( + date_project, + &result(&format!( + "#{{effects:[#{{id:\"person\",set:#{{name:{expression}}}}}]}}" + )), + ) + .expect("the helper call compiles"); + assert_eq!( + evaluate_action_at_date( + &action, + inputs().as_object().unwrap(), + date!(2026 - 10 - 14), + Instant::now() + Duration::from_secs(5), + ) + .unwrap_err(), + ActionHandlerError::Execution, + "{expression}" + ); + } +} + #[test] fn handler_business_refusal_is_exclusive_bounded_and_governed() { let outcome = evaluate(&result( diff --git a/crates/registry-breg/tests/immediate_action_requirements.rs b/crates/registry-breg/tests/immediate_action_requirements.rs index e609c56435..cde30e51fe 100644 --- a/crates/registry-breg/tests/immediate_action_requirements.rs +++ b/crates/registry-breg/tests/immediate_action_requirements.rs @@ -97,6 +97,50 @@ fn action_requirements_can_compare_a_target_field_to_a_typed_action_input() { ); } +#[test] +fn current_date_offsets_are_bounded_and_bound_to_current_date() { + let mut source = ordered_guard_project(); + source["project"]["timezone"] = json!("UTC"); + source["actions"][0]["commitGuards"]["targets"] = json!([{ + "input": "parent", "field": "opened-on", "currentDate": "on-or-after", + "currentDateOffsetDays": -30 + }]); + let registry = compile(source.clone()).expect("a signed whole-day offset compiles"); + let guard = ®istry.actions().actions[0].requires[0]; + assert_eq!(guard.current_date_offset_days, -30); + + source["actions"][0]["commitGuards"]["targets"][0]["currentDateOffsetDays"] = json!(0); + let explicit_zero = compile(source.clone()).expect("zero keeps the existing comparison"); + source["actions"][0]["commitGuards"]["targets"][0] + .as_object_mut() + .unwrap() + .remove("currentDateOffsetDays"); + let absent = compile(source.clone()).expect("an absent offset keeps the existing comparison"); + assert_eq!( + explicit_zero.actions().actions[0].requires[0], + absent.actions().actions[0].requires[0] + ); + + source["actions"][0]["commitGuards"]["targets"][0] + .as_object_mut() + .unwrap() + .remove("currentDate"); + source["actions"][0]["commitGuards"]["targets"][0]["currentDateOffsetDays"] = json!(1); + assert!(format!("{:?}", compile(source).unwrap_err()) + .contains("breg.action.commit_guards.current-date-offset-invalid")); + + for offset in [-365_001, 365_001] { + let mut source = ordered_guard_project(); + source["project"]["timezone"] = json!("UTC"); + source["actions"][0]["commitGuards"]["targets"] = json!([{ + "input": "parent", "field": "opened-on", "currentDate": "on-or-after", + "currentDateOffsetDays": offset + }]); + assert!(format!("{:?}", compile(source).unwrap_err()) + .contains("breg.action.commit_guards.current-date-offset-invalid")); + } +} + /// A project whose parent stores one ordered field of each kind and whose /// action takes a required input of each kind. fn ordered_guard_project() -> Value { diff --git a/crates/registry-breg/tests/postgres_access_log.rs b/crates/registry-breg/tests/postgres_access_log.rs index 9375ab0f3f..9f96f86484 100644 --- a/crates/registry-breg/tests/postgres_access_log.rs +++ b/crates/registry-breg/tests/postgres_access_log.rs @@ -356,6 +356,68 @@ async fn history(app: &Router, token: &str, record: &str, query: &str) -> (Statu .await } +#[tokio::test] +async fn direct_client_is_distinguished_in_allowed_and_refused_request_audit() { + let (db, app, idp) = setup().await; + let portal = token(&idp, "portal", SUBJECT); + let agency = token(&idp, "agency", SUBJECT); + let uri = format!("/v1/records/entries/{RECORD_A}"); + for bearer in [&portal, &agency] { + assert_eq!( + send(&app, bearer, Method::GET, &uri, None, None).await.0, + StatusCode::OK + ); + } + assert_eq!( + send( + &app, + &portal, + Method::GET, + &format!("{uri}?$select=subject"), + None, + None + ) + .await + .0, + StatusCode::NOT_FOUND + ); + + let records = db.audit_records(); + assert_eq!(records.len(), 5); + let profile = AuditProfile::production_from_secret_bytes(vec![0x52; 32].into()).unwrap(); + for (record, phase, client) in [ + (&records[0], "attempt", "portal"), + (&records[1], "terminal", "portal"), + (&records[2], "attempt", "agency"), + (&records[3], "terminal", "agency"), + (&records[4], "refusal", "portal"), + ] { + let activation = record["activationId"].as_str().unwrap(); + let client_reference = profile + .key_hasher() + .audit_reference_hash("breg-client-v1", activation, client) + .unwrap(); + assert_eq!(record["phase"], phase); + assert_eq!(record["clientReference"], client_reference); + assert_eq!( + record["principalReference"], + records[0]["principalReference"] + ); + assert!(record.get("authorization").is_none()); + } + let rendered = json!(records).to_string(); + for raw in [ + SUBJECT, + PURPOSE, + "\"portal\"", + "\"agency\"", + &portal, + &agency, + ] { + assert!(!rendered.contains(raw)); + } +} + #[tokio::test] async fn subject_access_log_requires_current_record_ownership_and_records_each_read_hit() { let (db, app, idp) = setup().await; diff --git a/crates/registry-breg/tests/postgres_facility_proposal.rs b/crates/registry-breg/tests/postgres_facility_proposal.rs index 09b8600aeb..6ee30c9b34 100644 --- a/crates/registry-breg/tests/postgres_facility_proposal.rs +++ b/crates/registry-breg/tests/postgres_facility_proposal.rs @@ -72,6 +72,16 @@ fn patterned_registry() -> registry_breg::CompiledRegistry { } fn registry_from(yaml: &str) -> registry_breg::CompiledRegistry { + registry_from_with_correction( + yaml, + include_bytes!("../../../products/breg/examples/facility/plans/correct-address.rhai"), + ) +} + +fn registry_from_with_correction( + yaml: &str, + correction_plan: &[u8], +) -> registry_breg::CompiledRegistry { let correction = yaml .split(" - id: correct-address\n") .nth(1) @@ -92,10 +102,7 @@ fn registry_from(yaml: &str) -> registry_breg::CompiledRegistry { &[ModuleAssetSource { module: None, path: "plans/correct-address.rhai".into(), - bytes: include_bytes!( - "../../../products/breg/examples/facility/plans/correct-address.rhai" - ) - .to_vec(), + bytes: correction_plan.to_vec(), }], CompileProfile::Authoring, ) @@ -778,6 +785,97 @@ async fn canonical_rhai_correction_freezes_reviews_applies_and_recovers_locally( fixture.finish().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn proposal_date_plan_is_frozen_at_submit_across_apply_date_boundary() { + let yaml = include_str!("../../../products/breg/examples/facility/registry.yaml").replacen( + " defaultLanguage: en\n", + " defaultLanguage: en\n timezone: UTC\n", + 1, + ); + let plan = br#" +fn plan(ctx) { + let current_date = Fn("today"); + #{ + effects: [#{ + id: "facility-address", + set: #{ + "address-line": current_date.call(), + "city": ctx.inputs["city"], + "postal-code": ctx.inputs["postal-code"] + } + }] + } +} +"#; + let registry = registry_from_with_correction(&yaml, plan); + let action = registry + .actions() + .actions + .iter() + .find(|action| action.id == "correct-address") + .unwrap(); + registry_breg::action_handler::set_test_action_evaluation_dates( + action, + vec![ + time::Date::from_calendar_date(2026, time::Month::January, 31).unwrap(), + time::Date::from_calendar_date(2026, time::Month::February, 1).unwrap(), + ], + ); + let (fixture, review) = fixture_for(registry).await; + let (author, facility, draft) = start(&fixture, "50000").await; + let submit = author + .client + .prepare_proposal(&draft, "submit", json!({}), &key("facility-date-submit")) + .unwrap(); + let submitted = author.client.execute_proposal(&submit).await.unwrap().value; + let action = fixture + .registry + .actions() + .actions + .iter() + .find(|action| action.id == "correct-address") + .unwrap(); + assert_eq!( + registry_breg::action_handler::remaining_test_action_evaluation_dates(action), + 1, + "Submit captures exactly one trusted date" + ); + assert_eq!( + address(&fixture, &facility).await.0, + "1 Main Road", + "submission freezes the plan without writing its target" + ); + + approve(&fixture, &submitted, &review).await; + assert!(fixture + .mutations + .run_automatic_proposal_once_for_test() + .await + .unwrap()); + assert_eq!( + address(&fixture, &facility).await, + ("2026-01-31".into(), "Chiang Mai".into(), "50000".into(), 2), + "Apply commits the date frozen at Submit" + ); + assert_eq!( + registry_breg::action_handler::remaining_test_action_evaluation_dates(action), + 1, + "Apply must not evaluate the handler again on the next date" + ); + assert_eq!( + author + .client + .read_proposal(&submitted) + .await + .unwrap() + .value + .state(), + BRegProposalState::Applied + ); + drop(author); + fixture.finish().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn canonical_rhai_refusal_current_revision_and_status_guards_recover_by_resubmission() { for stale_revision in [true, false] { diff --git a/crates/registry-breg/tests/postgres_immediate_action_requirements.rs b/crates/registry-breg/tests/postgres_immediate_action_requirements.rs index 6f1541b3e8..514ade1d94 100644 --- a/crates/registry-breg/tests/postgres_immediate_action_requirements.rs +++ b/crates/registry-breg/tests/postgres_immediate_action_requirements.rs @@ -255,6 +255,29 @@ async fn get_parent(mut app: axum::Router, id: &str) -> (StatusCode, Value) { (status, body) } +async fn set_parent_eligible_on( + database: &TestDatabase, + registry: ®istry_breg::CompiledRegistry, + date: chrono::NaiveDate, +) { + let parent = ®istry.entities()["parent"]; + database + .admin + .execute( + &format!( + "UPDATE registry_data.{} SET {} = $2::text::date WHERE record_id = $1", + q(&parent.physical_table), + q(&parent.fields["eligible-on"].physical_name) + ), + &[ + &Uuid::parse_str(ID).unwrap(), + &date.format("%Y-%m-%d").to_string(), + ], + ) + .await + .unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn action_input_equality_is_authorized_atomic_and_replayed_from_the_receipt() { let mut source = support::project(); @@ -301,6 +324,45 @@ async fn action_input_equality_is_authorized_atomic_and_replayed_from_the_receip database.cleanup().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn current_date_offset_is_enforced_at_immediate_commit() { + let mut source = support::project(); + source["project"]["timezone"] = json!("UTC"); + source["recordTypes"][0]["fields"] + .as_array_mut() + .unwrap() + .push(json!({ + "id":"eligible-on", "type":"date", "classification":"restricted" + })); + source["actions"][0]["commitGuards"]["targets"] + .as_array_mut() + .unwrap() + .push(json!({ + "input":"parent", "field":"eligible-on", "currentDate":"on-or-after", + "currentDateOffsetDays":30 + })); + let (database, registry, identity) = setup_with_project(source).await; + let today = registry + .local_date_at(time::OffsetDateTime::now_utc()) + .unwrap(); + let threshold = today.checked_add_days(chrono::Days::new(30)).unwrap(); + set_parent_eligible_on(&database, ®istry, threshold).await; + let app = app(&database, registry.clone(), identity, None); + let accepted = invoke(app.clone(), "date-offset-accepted", ID).await; + assert_eq!(accepted.0, StatusCode::OK, "{}", accepted.1); + let committed = counts(&database, ®istry).await; + + set_parent_eligible_on(&database, ®istry, today).await; + let refused = invoke(app, "date-offset-refused", ID).await; + assert_eq!(refused.0, StatusCode::PRECONDITION_FAILED, "{}", refused.1); + assert_eq!( + refused.1["code"], "precondition.failed", + "the profile cannot read eligible-on, so the failed guard stays concealed" + ); + assert_eq!(counts(&database, ®istry).await, committed); + database.cleanup().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn ordered_input_guards_compare_dates_and_instants_against_stored_fields() { let mut source = with_parent_get( diff --git a/crates/registry-breg/tests/postgres_proposal_guard_failure.rs b/crates/registry-breg/tests/postgres_proposal_guard_failure.rs index f1673caf79..f49c24c279 100644 --- a/crates/registry-breg/tests/postgres_proposal_guard_failure.rs +++ b/crates/registry-breg/tests/postgres_proposal_guard_failure.rs @@ -48,7 +48,14 @@ fn map(value: Value) -> Map { /// The guards read `status` and compare `capacity` with the `minimum` input; /// `readable` lists what the profile may read. fn registry(readable: &[&str]) -> registry_breg::CompiledRegistry { - let source = json!({"apiVersion":"id.registrystack.org/formats/breg/project/v1alpha1","kind":"BRegProject", + registry_with_current_date_offset(readable, None) +} + +fn registry_with_current_date_offset( + readable: &[&str], + offset_days: Option, +) -> registry_breg::CompiledRegistry { + let mut source = json!({"apiVersion":"id.registrystack.org/formats/breg/project/v1alpha1","kind":"BRegProject", "project":{"id":"guard-failure-test","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://guard.example.test"}, "datasets":[{"id":"directory"}], "recordTypes":[{"id":"target","primaryDataset":"directory","route":"targets","mutationMode":"mutable", @@ -77,6 +84,22 @@ fn registry(readable: &[&str]) -> registry_breg::CompiledRegistry { "proposals":[{"action":"correct-name","operations":["get","list","edit","submit","revise","rebase","retry","cancel","apply"], "ownership":"caller","targets":[{"recordType":"target", "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}]}]}]}}]}); + if let Some(offset_days) = offset_days { + source["project"]["timezone"] = json!("UTC"); + source["recordTypes"][0]["fields"] + .as_array_mut() + .unwrap() + .push(json!({ + "id":"eligible-on", "type":"date", "classification":"internal" + })); + source["actions"][0]["commitGuards"]["targets"] + .as_array_mut() + .unwrap() + .push(json!({ + "input":"target", "field":"eligible-on", "currentDate":"on-or-after", + "currentDateOffsetDays":offset_days, "reason":"outside-date-window" + })); + } let project = parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(); compile_project(&project, &[], CompileProfile::Authoring).expect("guard project compiles") } @@ -161,6 +184,26 @@ async fn bump_revision(fixture: &client_http::ClientFixture, record_id: &str) { .unwrap(); } +async fn set_eligible_on( + fixture: &client_http::ClientFixture, + record_id: &str, + date: chrono::NaiveDate, +) { + let entity = &fixture.registry.entities()["target"]; + fixture + .database + .admin + .execute( + &format!( + "UPDATE registry_data.\"{}\" SET \"{}\" = $2::text::date WHERE record_id = $1::text::uuid", + entity.physical_table, entity.fields["eligible-on"].physical_name + ), + &[&record_id, &date.format("%Y-%m-%d").to_string()], + ) + .await + .unwrap(); +} + async fn submit( fixture: &client_http::ClientFixture, draft: &BRegProposal, @@ -249,6 +292,40 @@ async fn apply_names_a_failed_field_guard_and_keeps_stale_revisions_generic() { fixture.finish().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn proposal_apply_reevaluates_the_configured_current_date_offset() { + let fixture = client_http::ClientFixture::start( + registry_with_current_date_offset( + &["name", "status", "capacity", "eligible-on", "jurisdiction"], + Some(30), + ), + claims(), + ) + .await; + let today = fixture + .registry + .local_date_at(time::OffsetDateTime::now_utc()) + .unwrap(); + let threshold = today.checked_add_days(chrono::Days::new(30)).unwrap(); + let (draft, target_id) = draft(&fixture, "open", 10, "date-guard-apply").await; + set_eligible_on(&fixture, &target_id, threshold).await; + let submitted = submit(&fixture, &draft, "date-guard-submit").await.unwrap(); + + // Change only the guarded date, not the record revision. Apply must read + // current state and enforce the same offset again instead of trusting the + // guard result observed at Submit. + set_eligible_on(&fixture, &target_id, today).await; + let refusal = apply(&fixture, &submitted, "date-guard-apply-key") + .await + .unwrap_err(); + assert_eq!( + refusal.problem_code(), + Some(BRegProblemCode::ActionGuardFailed) + ); + assert_eq!(refusal.guard_reason(), Some("outside-date-window")); + fixture.finish().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn submit_names_a_failed_input_comparison_guard() { let fixture = client_http::ClientFixture::start( diff --git a/crates/registry-breg/tests/registry_timezone.rs b/crates/registry-breg/tests/registry_timezone.rs index 0d64a9c6b4..6ecdbc19f7 100644 --- a/crates/registry-breg/tests/registry_timezone.rs +++ b/crates/registry-breg/tests/registry_timezone.rs @@ -158,6 +158,108 @@ fn a_current_date_requirement_requires_a_timezone() { compile(&source).expect("a declared time zone satisfies the requirement"); } +#[test] +fn a_plan_that_calls_today_requires_a_timezone() { + let mut source = action_support::project(); + source["functions"] = json!([{ + "id":"dated-plan", "runtime":"rhai", "source":"plans/dated.rhai", + "entrypoint":"plan", "abi":"registry.action-plan/v1" + }]); + source["actions"][0]["plan"] = json!({ + "type":"function", "function":"dated-plan", "inputs":["parent"], + "writes":[{"id":"child","target":{"recordType":"child"},"operation":"create","fields":["parent","label"]}] + }); + let assets = || { + vec![ModuleAssetSource { + module: None, + path: "plans/dated.rhai".into(), + bytes: br#"fn plan(ctx) { #{effects:[#{id:"child",set:#{parent:ctx.inputs.parent,label:today()}}]} }"#.to_vec(), + }] + }; + let failure = compile_with_assets(&source, assets()) + .expect_err("today() without a registry time zone is refused"); + let errors = timezone_errors(&failure); + let diagnostics = failure + .diagnostics() + .iter() + .map(|diagnostic| (&diagnostic.code, &diagnostic.path)) + .collect::>(); + assert_eq!(errors.len(), 1, "all diagnostics: {diagnostics:?}"); + assert!(errors[0].message.contains("today()")); + assert!(errors[0].message.contains("register-child")); + + source["project"]["timezone"] = json!("UTC"); + compile_with_assets(&source, assets()).expect("a fixed registry time zone supplies today()"); + + source["project"] + .as_object_mut() + .unwrap() + .remove("timezone"); + let pointer_assets = || { + vec![ModuleAssetSource { + module: None, + path: "plans/dated.rhai".into(), + bytes: br#" +fn plan(ctx) { + let current_date = Fn("today"); + #{effects:[#{id:"child",set:#{parent:ctx.inputs.parent,label:current_date.call()}}]} +} +"# + .to_vec(), + }] + }; + let Err(failure) = compile_with_assets(&source, pointer_assets()) else { + panic!("Fn(\"today\") without a registry time zone was accepted"); + }; + assert_eq!( + timezone_errors(&failure).len(), + 1, + "{:?}", + failure.diagnostics() + ); + + source["project"]["timezone"] = json!("UTC"); + compile_with_assets(&source, pointer_assets()) + .expect("a fixed registry time zone supplies Fn(\"today\")"); +} + +#[test] +fn a_user_defined_zero_argument_today_does_not_require_a_timezone() { + let mut source = action_support::project(); + source["functions"] = json!([{ + "id":"dated-plan", "runtime":"rhai", "source":"plans/dated.rhai", + "entrypoint":"plan", "abi":"registry.action-plan/v1" + }]); + source["actions"][0]["plan"] = json!({ + "type":"function", "function":"dated-plan", "inputs":["parent"], + "writes":[{"id":"child","target":{"recordType":"child"},"operation":"create","fields":["parent","label"]}] + }); + let asset = ModuleAssetSource { + module: None, + path: "plans/dated.rhai".into(), + bytes: br#" +fn today() { "authored-date" } +fn plan(ctx) { + let authored_today = Fn("today"); + #{effects:[#{id:"child",set:#{parent:ctx.inputs.parent,label:authored_today.call()}}]} +} +"# + .to_vec(), + }; + let compiled = compile_with_assets(&source, vec![asset]) + .expect("an authored today() function is not the trusted host helper"); + let action = &compiled.actions().actions[0]; + let handler = action.handler.as_ref().unwrap(); + assert!(!handler.uses_today); + assert!( + serde_json::to_value(handler) + .unwrap() + .get("usesToday") + .is_none(), + "the false metadata stays absent from the legacy digest payload" + ); +} + #[test] fn a_live_derived_field_reading_the_evaluation_date_requires_a_timezone() { let mut source = date_interval_project("date"); diff --git a/crates/registry-breg/tests/support/action_evidence_runtime.rs b/crates/registry-breg/tests/support/action_evidence_runtime.rs index 784f841596..0fdabf0e67 100644 --- a/crates/registry-breg/tests/support/action_evidence_runtime.rs +++ b/crates/registry-breg/tests/support/action_evidence_runtime.rs @@ -79,6 +79,7 @@ fn queued_worker_retains_capacity_after_request_cancellation() { .evaluate( first_action, inputs(), + None, Instant::now() + Duration::from_secs(3), ) .await @@ -88,6 +89,7 @@ fn queued_worker_retains_capacity_after_request_cancellation() { .evaluate( compiled, inputs(), + None, Instant::now() + Duration::from_millis(30), ) .await; @@ -122,6 +124,7 @@ async fn paused_provider_cancellation_and_deadline_release_workers() { .evaluate( first_action, inputs(), + None, Instant::now() + Duration::from_secs(3), ) .await @@ -139,6 +142,7 @@ async fn paused_provider_cancellation_and_deadline_release_workers() { .evaluate( compiled.clone(), inputs(), + None, Instant::now() + Duration::from_millis(100), ) .await; @@ -152,7 +156,12 @@ async fn paused_provider_cancellation_and_deadline_release_workers() { ); provider.resume(); let successful = evaluator - .evaluate(compiled, inputs(), Instant::now() + Duration::from_secs(3)) + .evaluate( + compiled, + inputs(), + None, + Instant::now() + Duration::from_secs(3), + ) .await; assert!( matches!(successful.outcome, Ok(ActionHandlerOutcome::Effects(_))), @@ -217,7 +226,12 @@ async fn runtime_dependency_failure_carries_the_verified_alias_only() { let evaluator = ActionEvidenceEvaluator::new(Arc::new(EvidenceActionClient::new(BTreeMap::new()))); let result = evaluator - .evaluate(action(), inputs(), Instant::now() + Duration::from_secs(2)) + .evaluate( + action(), + inputs(), + None, + Instant::now() + Duration::from_secs(2), + ) .await; assert!(matches!(result.outcome, Err(MutationError::ActionEvidenceFailure { capability: Some(ref id) }) if id == "farmer-status")); diff --git a/crates/registry-bregctl/src/action_handler_test.rs b/crates/registry-bregctl/src/action_handler_test.rs index 6280a51d47..9998c1b5f7 100644 --- a/crates/registry-bregctl/src/action_handler_test.rs +++ b/crates/registry-bregctl/src/action_handler_test.rs @@ -2,8 +2,8 @@ //! Synthetic action handler evaluation and value-free exact assertions. use super::*; use registry_breg::action_handler::{ - evaluate_action_detailed, evaluate_action_with_evidence, ActionHandlerError, - ActionHandlerOutcome, + evaluate_action_detailed, evaluate_action_detailed_at_date, evaluate_action_with_evidence, + evaluate_action_with_evidence_at_date, ActionHandlerError, ActionHandlerOutcome, }; use registry_breg::model::{ CompiledPlanEffect, CompiledPlanFunction, CompiledPlanFunctionKind, CompiledPlanMutation, @@ -29,6 +29,7 @@ pub(super) fn run( .as_ref() .ok_or_else(|| failure("action", "select an action with a Rhai handler"))?; let identity = handler_identity(handler)?; + let evaluation_date = evaluation_date(args, handler)?; let path = args .input .as_ref() @@ -55,16 +56,33 @@ pub(super) fn run( })); let evaluation = if !action.evidence.is_empty() { let state = transcript.clone(); - evaluate_action_with_evidence( + let resolver = move |capability: &str, selectors: Value| { + state + .lock() + .map_err(|_| ActionHandlerError::Execution)? + .resolve(capability, selectors) + }; + match evaluation_date { + Some(date) => evaluate_action_with_evidence_at_date( + action, + input, + date, + Instant::now() + PLANNER_TEST_DEADLINE, + resolver, + ), + None => evaluate_action_with_evidence( + action, + input, + Instant::now() + PLANNER_TEST_DEADLINE, + resolver, + ), + } + } else if let Some(date) = evaluation_date { + evaluate_action_detailed_at_date( action, input, + date, Instant::now() + PLANNER_TEST_DEADLINE, - move |capability, selectors| { - state - .lock() - .map_err(|_| ActionHandlerError::Execution)? - .resolve(capability, selectors) - }, ) } else { evaluate_action_detailed(action, input, Instant::now() + PLANNER_TEST_DEADLINE) @@ -245,6 +263,41 @@ pub(super) fn run( }) } +fn evaluation_date( + args: &ProjectActionTestArgs, + handler: &CompiledPlanFunction, +) -> Result, FailureReport> { + let parsed = args + .evaluation_date + .as_deref() + .map(|value| { + if value.len() != 10 { + return Err(()); + } + time::Date::parse( + value, + time::macros::format_description!("[year]-[month]-[day]"), + ) + .map_err(|_| ()) + }) + .transpose() + .map_err(|_| { + action_test_failure( + "action-test.evaluation-date-invalid", + "evaluation-date", + "use a calendar date in YYYY-MM-DD form", + ) + })?; + if handler.uses_today && parsed.is_none() { + return Err(action_test_failure( + "action-test.evaluation-date-required", + "evaluation-date", + "this handler calls today(); supply --evaluation-date YYYY-MM-DD", + )); + } + Ok(parsed) +} + /// The compiled backend this command executes, and the source fingerprint the /// report names for it. /// @@ -487,6 +540,7 @@ mod tests { action: Some(action.into()), input: Some(project.join(format!("examples/{branch}.input.json"))), expect: Some(project.join(format!("examples/{branch}.expect.json"))), + evaluation_date: None, }; let report = run(&args, &compiled) .unwrap_or_else(|failure| panic!("{}", serde_json::to_string(&failure).unwrap())); @@ -497,6 +551,34 @@ mod tests { } } + #[test] + fn action_test_uses_the_authored_fixed_evaluation_date() { + let project = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/breg/fixtures/action-plan-branches") + .canonicalize() + .unwrap(); + let compiled = compile(&project, ProfileArg::Authoring, "project action-test") + .unwrap_or_else(|failure| panic!("{}", serde_json::to_string(&failure).unwrap())); + let args = ProjectActionTestArgs { + project: project.clone(), + action: Some("create-dated-entry".into()), + input: Some(project.join("examples/date.input.json")), + expect: Some(project.join("examples/date.expect.json")), + evaluation_date: Some("2026-01-31".into()), + }; + let report = run(&args, &compiled) + .unwrap_or_else(|failure| panic!("{}", serde_json::to_string(&failure).unwrap())); + assert_eq!(report.assertions_passed, Some(true)); + + let mut missing = args; + missing.evaluation_date = None; + let failure = run(&missing, &compiled).expect_err("today() needs a fixed test date"); + assert_eq!( + failure.diagnostics[0].code, + "action-test.evaluation-date-required" + ); + } + #[test] fn handler_failure_expectations_are_exact_and_do_not_accept_invalid_inputs() { let project = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -517,6 +599,7 @@ mod tests { action: Some("invalid-copy".into()), input: Some(project.join("examples/handler-failure.input.json")), expect: Some(expectation.clone()), + evaluation_date: None, }; let failure = run(&args, &compiled).unwrap_err(); assert_eq!(failure.diagnostics[0].code, "action-test.expect.mismatch"); @@ -556,6 +639,7 @@ mod tests { action: Some("invalid-copy".into()), input: Some(project.join("examples/handler-failure.input.json")), expect: Some(expect), + evaluation_date: None, }, &compiled, ) @@ -607,6 +691,7 @@ mod tests { action: Some("register-landholding".into()), input: Some(project.join("examples/status-only.input.json")), expect: Some(expect.clone()), + evaluation_date: None, }, &compiled, ); @@ -724,6 +809,7 @@ accessProfiles: action: Some("register-person".into()), input: Some(input), expect: None, + evaluation_date: None, }; let failure = run(&args, &compiled).expect_err("a WASM handler is not executed locally"); let diagnostic = &failure.diagnostics[0]; @@ -755,6 +841,7 @@ accessProfiles: rhai_version: None, script_sha256: Some("sha256:script-digest".into()), module_sha256: None, + uses_today: false, script_bytes: Vec::new(), module_bytes: Vec::new(), limits: None, @@ -845,6 +932,7 @@ accessProfiles: ), input: Some(project.join(format!("examples/{name}.input.json"))), expect: Some(project.join(format!("examples/{name}.expect.json"))), + evaluation_date: None, }; let report = run(&args, &compiled).unwrap_or_else(|failure| { panic!("{name}: {}", serde_json::to_string(&failure).unwrap()) @@ -884,6 +972,7 @@ accessProfiles: action: Some("register-landholding".into()), input: Some(project.join("examples/status-only.input.json")), expect: Some(expect.clone()), + evaluation_date: None, }; let failure = run(&args, &compiled).expect_err("transcript mismatch must fail"); assert_eq!(failure.diagnostics[0].code, code); @@ -925,6 +1014,7 @@ accessProfiles: action: Some("register-landholding".into()), input: Some(project.join("examples/status-only.input.json")), expect: Some(expect.clone()), + evaluation_date: None, }; let failure = run(&args, &compiled).expect_err("typed mock contract must fail"); assert_eq!( @@ -984,7 +1074,9 @@ accessProfiles: "--input", "input.json", "--expect", - "expect.json" + "expect.json", + "--evaluation-date", + "2026-01-31" ]) .is_ok()); let help = Cli::try_parse_from(["bregctl", "project", "action-test", "--help"]) @@ -994,6 +1086,7 @@ accessProfiles: "pair with --input", "With --action and --input", "without an HTTP envelope", + "Fixed trusted date", ] { assert!(help.contains(guidance), "{help}"); } @@ -1020,6 +1113,7 @@ accessProfiles: action: Some("register-person".into()), input: Some(input.clone()), expect: Some(expect.clone()), + evaluation_date: None, }; let report = run(&args, &compiled) .unwrap_or_else(|failure| panic!("evaluation failed: {}", failure.diagnostics[0].code)); @@ -1094,6 +1188,7 @@ accessProfiles: action: Some("register-person".into()), input: Some(input_path.clone()), expect: None, + evaluation_date: None, }; for (input, expected_path) in [ ( diff --git a/crates/registry-bregctl/src/explain_report.rs b/crates/registry-bregctl/src/explain_report.rs index 73eba374ef..345e1df0b7 100644 --- a/crates/registry-bregctl/src/explain_report.rs +++ b/crates/registry-bregctl/src/explain_report.rs @@ -161,12 +161,22 @@ pub(crate) fn actions(value: &Value, lines: &mut Lines) { ("equals", "equals"), ("atLeast", "at least"), ("atMost", "at most"), - ("currentDate", "current date"), ] { if let Some(value) = guard.get(key) { terms.push(format!("{label} {value}")); } } + if let Some(comparison) = guard.get("currentDate") { + let offset = guard["currentDateOffsetDays"] + .as_i64() + .filter(|days| *days != 0); + terms.push(match (comparison.as_str(), offset) { + (Some(comparison), Some(days)) => { + format!("current date {comparison} ({days:+} days)") + } + _ => format!("current date {comparison}"), + }); + } lines.listed( 2, &format!( @@ -607,6 +617,27 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn action_summary_names_a_current_date_offset() { + let value = json!([{ + "id":"renew", "execution":{"mode":"immediate"}, "inputs":[], + "plan":{"effects":[]}, + "commitGuards":[{ + "input":"licence", "field":"valid-through", "revisionUnchanged":false, + "currentDate":"on-or-after", "currentDateOffsetDays":30 + }], + "permissions":[] + }]); + let mut lines = Lines::new(); + actions(&value, &mut lines); + let rendered = lines.finish(); + let text = rendered.split_whitespace().collect::>().join(" "); + assert!( + text.contains("current date on-or-after (+30 days)"), + "{rendered}" + ); + } + #[test] fn permission_gates_quote_literal_identifiers_and_label_empty_lists() { for identifiers in [ diff --git a/crates/registry-bregctl/src/lib.rs b/crates/registry-bregctl/src/lib.rs index 473bb6da06..5aa9968203 100644 --- a/crates/registry-bregctl/src/lib.rs +++ b/crates/registry-bregctl/src/lib.rs @@ -606,6 +606,10 @@ struct ProjectActionTestArgs { /// With --action and --input, assert exact effects or refusal and optional ordered evidenceCalls mocks without printing values. #[arg(long, value_name = "JSON_FILE", requires = "action")] expect: Option, + + /// Fixed trusted date exposed to today(), in YYYY-MM-DD form. + #[arg(long, value_name = "YYYY-MM-DD", requires = "action")] + evaluation_date: Option, } #[derive(Debug, Args)] diff --git a/crates/registry-casework-client-node/__test__/facade.test-d.ts b/crates/registry-casework-client-node/__test__/facade.test-d.ts index f8fc38cd95..43b5902421 100644 --- a/crates/registry-casework-client-node/__test__/facade.test-d.ts +++ b/crates/registry-casework-client-node/__test__/facade.test-d.ts @@ -23,8 +23,9 @@ declare const explainedStage: ReviewStagePolicy declare const explainedRule: RoutingRule const policyLabel: string | undefined = explainedPolicy.label const stageLabel: string | undefined = explainedStage.label +const stageOutcomes: ReadonlyArray | undefined = explainedStage.outcomes const ruleLabel: string | undefined = explainedRule.label -void policyLabel; void stageLabel; void ruleLabel +void policyLabel; void stageLabel; void stageOutcomes; void ruleLabel const submitted: ReviewCreateRequest = { policy, subject, requesterReference: 'proposal-1', context: { mode: 'submitted', snapshot: { reference: 'proposal-1' } } } const current: ReviewCreateRequest = { ...submitted, context: { mode: 'current-source', binding: { reference: 'proposal-1' } } } void client.createOrRecoverReviewRequest(token, 'requester', 'submit-1', current, digest) diff --git a/crates/registry-casework-client-node/client.d.ts b/crates/registry-casework-client-node/client.d.ts index 6b894d7497..c551334dc1 100644 --- a/crates/registry-casework-client-node/client.d.ts +++ b/crates/registry-casework-client-node/client.d.ts @@ -364,7 +364,7 @@ export interface ReviewAccountabilityRecord { occurredAt: string; retainedUntil: string decisionReceipt?: ReviewDecisionReceipt } -export interface ReviewStagePolicy { id: string; label?: string; queue: string; decidingProfiles: ReadonlyArray; requiredApprovals: SafeInteger; negativeDecisions?: 'settle' | 'advise'; excludeInitiator?: boolean; excludePreviousStageReviewers?: boolean } +export interface ReviewStagePolicy { id: string; label?: string; queue: string; decidingProfiles: ReadonlyArray; requiredApprovals: SafeInteger; outcomes?: ReadonlyArray; negativeDecisions?: 'settle' | 'advise'; excludeInitiator?: boolean; excludePreviousStageReviewers?: boolean } export interface ReviewRetentionPolicy { terminalRetentionDays: SafeInteger; accountabilityRetentionDays: SafeInteger } export interface ReviewContextContract { id: string; revision: ContentDigest; schemaDigest: ContentDigest } export type ReviewContextPolicy = { mode: 'submitted' } | { mode: 'current-source'; provider: string; contract: ReviewContextContract } diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index 9c00825569..7f96cd5166 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -745,6 +745,7 @@ class ReviewStagePolicy(TypedDict): queue: str decidingProfiles: list[str] requiredApprovals: SafeInteger + outcomes: NotRequired[list[str]] negativeDecisions: NotRequired[Literal["settle", "advise"]] excludeInitiator: NotRequired[bool] excludePreviousStageReviewers: NotRequired[bool] diff --git a/crates/registry-casework-client-py/tests/python/test_typing_contract.py b/crates/registry-casework-client-py/tests/python/test_typing_contract.py index 5bed80fc55..29f446c189 100644 --- a/crates/registry-casework-client-py/tests/python/test_typing_contract.py +++ b/crates/registry-casework-client-py/tests/python/test_typing_contract.py @@ -509,6 +509,7 @@ def test_review_model_has_no_parallel_source_action_lifecycle(self) -> None: annotations["negativeDecisions"], "NotRequired[Literal['settle', 'advise']]", ) + self.assertEqual(annotations["outcomes"], "NotRequired[list[str]]") self.assertEqual(annotations["label"], "NotRequired[str]") routing = classes["RoutingRule"] routing_annotations = { diff --git a/crates/registry-casework-client/src/wire.rs b/crates/registry-casework-client/src/wire.rs index 457033027f..5ca8e18075 100644 --- a/crates/registry-casework-client/src/wire.rs +++ b/crates/registry-casework-client/src/wire.rs @@ -192,6 +192,8 @@ struct ReviewStagePolicyWire { queue: String, deciding_profiles: Vec, required_approvals: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + outcomes: Option>, #[serde(default)] negative_decisions: registry_casework_core::ReviewNegativeDecisionMode, #[serde(default)] diff --git a/crates/registry-casework-client/tests/http_boundary.rs b/crates/registry-casework-client/tests/http_boundary.rs index 05cd1f7067..b0b8284678 100644 --- a/crates/registry-casework-client/tests/http_boundary.rs +++ b/crates/registry-casework-client/tests/http_boundary.rs @@ -44,7 +44,8 @@ async fn ordinary_nested_response_members_are_ignored() { "routing": [], "stages": [{ "id": "review", "queue": "reviews", "decidingProfiles": ["staff"], - "requiredApprovals": 1, "futureStageMember": true + "requiredApprovals": 1, "outcomes": ["approved"], + "futureStageMember": true }], "retention": { "terminalRetentionDays": 30, @@ -56,6 +57,9 @@ async fn ordinary_nested_response_members_are_ignored() { "id": "approved", "label": "Approve", "settlement": "approved", "reasonRequired": false, "publicReason": "optional", "futureOutcomeMember": true + }, { + "id": "rejected", "label": "Reject", "settlement": "rejected", + "reasonRequired": true, "publicReason": "optional" }], "futurePolicyMember": true }], @@ -148,6 +152,10 @@ async fn ordinary_nested_response_members_are_ignored() { .await .expect("description with additive members"); assert_eq!(returned.value.review_policies[0].id, "registry-correction"); + assert_eq!( + returned.value.review_policies[0].stages[0].outcomes, + Some(vec!["approved".to_owned()]) + ); let returned = client .directory(CaseworkAuth::new(&token, "staff")) .await @@ -187,6 +195,7 @@ async fn review_policy_refuses_a_valid_snapshot_for_another_identifier() { queue: "reviews".to_owned(), deciding_profiles: vec!["staff".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: false, exclude_previous_stage_reviewers: false, @@ -251,6 +260,7 @@ async fn review_policy_refuses_unverified_governed_response_members() { queue: "reviews".to_owned(), deciding_profiles: vec!["staff".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: false, exclude_previous_stage_reviewers: false, @@ -318,6 +328,7 @@ async fn review_policy_refuses_duplicate_additive_response_members() { queue: "reviews".to_owned(), deciding_profiles: vec!["staff".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: false, exclude_previous_stage_reviewers: false, @@ -2260,6 +2271,7 @@ fn context_body(strategy: ReviewContextPolicy, display_schema: Value, context: V queue: "answers".to_owned(), deciding_profiles: vec!["staff".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: false, exclude_previous_stage_reviewers: false, @@ -3439,6 +3451,7 @@ async fn task_context_response() -> impl IntoResponse { queue: "answers".to_owned(), deciding_profiles: vec!["staff".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: false, exclude_previous_stage_reviewers: false, diff --git a/crates/registry-casework-core/src/fixture.rs b/crates/registry-casework-core/src/fixture.rs index 13c3f660b6..0fa5af3216 100644 --- a/crates/registry-casework-core/src/fixture.rs +++ b/crates/registry-casework-core/src/fixture.rs @@ -267,7 +267,7 @@ impl CaseworkFixture { "casework.fixture.empty-outcomes", "/expect/outcomes", "expected at least one outcome identifier", - "List the policy's configured outcomes, or omit outcomes and assert a queue.", + "List the first review stage's outcomes, or omit outcomes and assert a queue.", )); } if findings.is_empty() { @@ -278,7 +278,7 @@ impl CaseworkFixture { } /// Validate the synthetic display under the exact compiled policy closure. - /// Queue expectations name the first stage queue; no source/runtime is read. + /// Expectations name the first stage's queue and outcomes. The check is offline. pub fn read_against( file: &str, bytes: &[u8], @@ -328,9 +328,13 @@ impl CaseworkFixture { )), } if let Some(expected) = &fixture.expect.outcomes { + let first_stage = policy.stages.first(); let actual = policy .outcomes .iter() + .filter(|outcome| { + first_stage.is_none_or(|stage| stage.offers_outcome(&outcome.id)) + }) .map(|outcome| outcome.id.as_str()) .collect::>(); let mut unknown = false; @@ -340,8 +344,8 @@ impl CaseworkFixture { findings.push(crate::ConfigFinding::new( "casework.fixture.unknown-outcome", format!("/expect/outcomes/{index}"), - "the outcome is not declared by the selected policy", - "Name an outcome declared under the selected review policy.", + "the first review stage does not offer this outcome", + "Choose an outcome offered by the first review stage.", )); } } @@ -349,8 +353,8 @@ impl CaseworkFixture { findings.push(crate::ConfigFinding::new( "casework.fixture.outcomes-mismatch", "/expect/outcomes", - "the expected outcomes differ from the policy's configured outcomes", - "List every configured outcome in policy order.", + "the expected outcomes differ from the first stage's choices", + "List every outcome offered by the first review stage in policy order.", )); } } @@ -395,3 +399,38 @@ impl CaseworkFixture { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage_outcomes_bound_offline_fixtures_to_the_first_stage() { + let mut project = crate::CaseworkProject::read( + "casework.yaml", + include_bytes!("../../../products/casework/examples/stage-outcomes/casework.yaml"), + ) + .unwrap() + .value; + project.review_policies[0].stages[0] + .outcomes + .as_mut() + .unwrap() + .retain(|id| id != "rejected"); + let fixture = include_str!( + "../../../products/casework/examples/stage-outcomes/fixtures/grant-review.yaml" + ); + let narrowed = fixture.replace( + "outcomes: [approved, changes-requested, rejected]", + "outcomes: [approved, changes-requested]", + ); + CaseworkFixture::read_against("grant-review.yaml", narrowed.as_bytes(), &project).unwrap(); + let refusal = + CaseworkFixture::read_against("grant-review.yaml", fixture.as_bytes(), &project) + .unwrap_err(); + assert!(refusal + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "casework.fixture.unknown-outcome")); + } +} diff --git a/crates/registry-casework-core/src/review.rs b/crates/registry-casework-core/src/review.rs index 948778919f..ad4ad849e7 100644 --- a/crates/registry-casework-core/src/review.rs +++ b/crates/registry-casework-core/src/review.rs @@ -237,6 +237,21 @@ pub struct ReviewStagePolicy { ) )] pub required_approvals: u16, + /// Outcome identifiers offered by this stage. Omission offers the whole + /// policy catalogue. + #[serde( + default, + deserialize_with = "crate::typed::optional_unique_local_ids", + skip_serializing_if = "Option::is_none" + )] + #[cfg_attr( + feature = "schema", + schemars( + with = "Option>", + length(min = 1) + ) + )] + pub outcomes: Option>, /// Controls whether a rejection or change request settles the whole review. /// `settle` is the default; `advise` records and counts the decision toward /// this non-final stage without settling the review. @@ -267,6 +282,49 @@ impl ReviewNegativeDecisionMode { } impl ReviewStagePolicy { + /// Tests a policy outcome's membership in the stage's optional subset. + #[must_use] + pub fn offers_outcome(&self, id: &str) -> bool { + self.outcomes + .as_ref() + .is_none_or(|outcomes| outcomes.iter().any(|outcome| outcome == id)) + } + + fn outcome_findings(&self, at: &str, declared: &[ReviewOutcomePolicy]) -> Findings { + let mut findings = Findings::default(); + let Some(outcomes) = &self.outcomes else { + return findings; + }; + if outcomes.is_empty() { + findings.push( + "casework.review-policy.no-stage-outcomes", + at.to_owned(), + "a stage needs at least one outcome that can settle or advance the review", + "List at least one outcome from the review policy, or omit outcomes to offer all of them.", + ); + } + findings.repeated( + outcomes + .iter() + .enumerate() + .map(|(index, outcome)| (format!("{at}/{index}"), outcome.as_str())), + "casework.review-policy.duplicate-stage-outcome", + "this stage already offers the outcome", + "List each outcome only once.", + ); + for (index, id) in outcomes.iter().enumerate() { + if !declared.iter().any(|outcome| outcome.id == *id) { + findings.push( + "casework.review-policy.unknown-stage-outcome", + format!("{at}/{index}"), + "this outcome is not declared by the review policy", + "Choose an identifier from the policy's outcomes.", + ); + } + } + findings + } + /// Identity findings and threshold findings, relative to `at`. The /// reader refuses a file whose threshold is out of range; the threshold /// findings hold the same bound for a policy built in code. @@ -735,6 +793,12 @@ impl ReviewPolicy { } } tag(ReviewPolicyError::Outcomes, outcomes); + for (index, stage) in self.stages.iter().enumerate() { + tag( + ReviewPolicyError::Outcomes, + stage.outcome_findings(&format!("/stages/{index}/outcomes"), &self.outcomes), + ); + } out } @@ -1758,9 +1822,8 @@ pub fn record_review_decision( ), _ => return Err(ReviewDecisionError::DecisionNotAllowed), }; - validate_settlement_outcome( - policy, - expected, + validate_outcome_choice(policy, stage, expected, outcome)?; + policy.validate_outcome( outcome, reason, public_reason.as_deref(), @@ -1878,15 +1941,16 @@ pub fn record_review_decision( } } -fn validate_settlement_outcome( +fn validate_outcome_choice( policy: &ReviewPolicyClosure, + stage: &ReviewStagePolicy, expected: ReviewOutcomeSettlement, outcome: &str, - reason: Option<&str>, - public_reason: Option<&str>, - result: Option<&Value>, - constraints: Option<&Value>, ) -> Result<(), ReviewDecisionError> { + let offered = policy + .outcomes + .iter() + .filter(|candidate| stage.offers_outcome(&candidate.id)); if policy .outcomes .iter() @@ -1895,15 +1959,19 @@ fn validate_settlement_outcome( { return Err(ReviewDecisionError::OutcomeNotAllowed { settlement: expected, - declared: policy - .outcomes - .iter() + declared: offered .filter(|candidate| candidate.settlement == expected) .map(|candidate| candidate.id.clone()) .collect(), }); } - policy.validate_outcome(outcome, reason, public_reason, result, constraints)?; + if !stage.offers_outcome(outcome) { + let allowed = offered.map(|candidate| candidate.id.clone()).collect(); + let problem = + ReviewValidationError::new("$.outcome", ReviewValidationReason::OutcomeNotDeclared) + .allowing(allowed); + return Err(ReviewDecisionValidationError::Structured(problem).into()); + } Ok(()) } @@ -2888,6 +2956,7 @@ mod tests { queue: "review".to_owned(), deciding_profiles: vec!["reviewer".to_owned()], required_approvals, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: true, exclude_previous_stage_reviewers: true, @@ -3030,6 +3099,103 @@ mod tests { } } + fn policy_with_final_outcomes(outcomes: &[&str]) -> ReviewPolicy { + let mut policy = approval_policy(vec![stage("local", 1), stage("final", 1)]); + policy.stages[1].outcomes = Some(outcomes.iter().map(|id| (*id).to_owned()).collect()); + policy + } + + #[test] + fn stage_outcomes_reject_empty_unknown_and_duplicate_choices() { + for (outcomes, code) in [ + (&[][..], "casework.review-policy.no-stage-outcomes"), + ( + &["approved", "unknown"], + "casework.review-policy.unknown-stage-outcome", + ), + ( + &["approved", "approved"], + "casework.review-policy.duplicate-stage-outcome", + ), + ] { + let policy = policy_with_final_outcomes(outcomes); + let findings = policy.findings(); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].code, code); + assert!(findings[0].pointer.starts_with("/stages/1/outcomes")); + } + } + + #[test] + fn stage_outcomes_limit_decisions_on_the_frozen_policy() { + let snapshot = policy_with_final_outcomes(&["approved", "rejected"]) + .closure_with(crate::ReviewPolicyDependencies::default()) + .unwrap(); + let request_id = Uuid::from_u128(100); + let local = task(request_id, 0, "local", 1, "a"); + let final_review = task(request_id, 1, "final", 2, "b"); + let request_changes = |task: &ReviewTask, reviewer: &str| Decision { + decision: DecisionKind::ChangesRequested { + outcome: "changes-requested".to_owned(), + reason: Some("Supply a corrected form".to_owned()), + public_reason: None, + result: None, + }, + ..approve(task, person(reviewer)) + }; + + let mut returned = ReviewProgress::new(request_id, None, None, &snapshot).unwrap(); + assert!(matches!( + record_review_decision( + &snapshot, + &mut returned, + &local, + request_changes(&local, "a") + ) + .unwrap(), + ReviewTransition::Settled { + settlement: ReviewSettlement::ChangesRequested { .. } + } + )); + + let mut progress = ReviewProgress::new(request_id, None, None, &snapshot).unwrap(); + record_review_decision( + &snapshot, + &mut progress, + &local, + approve(&local, person("a")), + ) + .unwrap(); + let before = progress.clone(); + let error = record_review_decision( + &snapshot, + &mut progress, + &final_review, + request_changes(&final_review, "b"), + ) + .unwrap_err(); + assert!(matches!( + error, + ReviewDecisionError::Validation(ReviewDecisionValidationError::Structured(problem)) + if problem.path == "$.outcome" + && problem.reason == ReviewValidationReason::OutcomeNotDeclared + && problem.allowed.as_ref() == ["approved", "rejected"] + )); + assert_eq!(progress, before); + assert!(matches!( + record_review_decision( + &snapshot, + &mut progress, + &final_review, + approve(&final_review, person("b")) + ) + .unwrap(), + ReviewTransition::Settled { + settlement: ReviewSettlement::Approved { .. } + } + )); + } + #[test] fn stage_thresholds_are_independent_and_ordered() { let snapshot = approval_policy(vec![stage("technical", 2), stage("authority", 1)]) @@ -3186,6 +3352,9 @@ mod tests { ); snapshot.stages[0].exclude_initiator = false; assert_eq!(snapshot.verify(), Err(ReviewPolicyError::DigestMismatch)); + snapshot.stages[0].exclude_initiator = true; + snapshot.stages[0].outcomes = Some(vec!["approved".to_owned()]); + assert_eq!(snapshot.verify(), Err(ReviewPolicyError::DigestMismatch)); let mut advisory_snapshot = approval_policy(vec![stage("first", 1), stage("final", 1)]) .closure_with(crate::ReviewPolicyDependencies::default()) @@ -3250,6 +3419,7 @@ mod tests { .map(|profile_index| identifier('p', profile_index)) .collect(), required_approvals: MAXIMUM_REVIEW_APPROVALS_PER_STAGE, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: true, exclude_previous_stage_reviewers: true, diff --git a/crates/registry-casework-core/src/schema.rs b/crates/registry-casework-core/src/schema.rs index 4ade31096a..b8ceb6d254 100644 --- a/crates/registry-casework-core/src/schema.rs +++ b/crates/registry-casework-core/src/schema.rs @@ -331,6 +331,32 @@ mod tests { .any(|required| required == "negativeDecisions")); } + #[test] + fn project_schema_and_reader_refuse_duplicate_stage_outcomes() { + let documents = project_documents().unwrap(); + let document: Value = serde_json::from_str(&documents[PROJECT_SCHEMA_FILE]).unwrap(); + let schema = compiled(&document); + let file = "casework.yaml"; + let authored = + include_str!("../../../products/casework/examples/stage-outcomes/casework.yaml"); + let decoded = crate::CaseworkProject::read(file, authored.as_bytes()).unwrap(); + let mut duplicate = decoded.document.to_json_value(); + assert!(schema.is_valid(&duplicate)); + duplicate["reviewPolicies"]["grant-review"]["stages"][1]["outcomes"] = + serde_json::json!(["approved", "approved"]); + assert!(!schema.is_valid(&duplicate)); + + let duplicate = authored.replace( + "outcomes: [approved, rejected]", + "outcomes: [approved, approved]", + ); + let report = crate::CaseworkProject::read(file, duplicate.as_bytes()).unwrap_err(); + assert!(report.diagnostics().iter().any(|diagnostic| { + diagnostic.code == "config.duplicate-item" + && diagnostic.path == "/reviewPolicies/grant-review/stages/1/outcomes/1" + })); + } + #[test] fn context_contract_id_and_service_profile_keys_publish_the_reader_contract() { let documents = project_documents().unwrap(); diff --git a/crates/registry-casework-core/src/typed.rs b/crates/registry-casework-core/src/typed.rs index ce43f4e7ac..ca836b63c9 100644 --- a/crates/registry-casework-core/src/typed.rs +++ b/crates/registry-casework-core/src/typed.rs @@ -91,6 +91,17 @@ where UniqueList::::deserialize(deserializer).map(UniqueList::into_vec) } +/// An optional set of local identifiers, kept in authored order. +pub(crate) fn optional_unique_local_ids<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let ids = Option::>::deserialize(deserializer)?; + Ok(ids.map(|ids| ids.into_iter().map(LocalId::into_string).collect())) +} + /// Named items, unique by `id` (CFG-ID-5): a repeated id is refused at the /// second item's `id`. pub fn unique_id_list<'de, D, T>(deserializer: D) -> Result, D::Error> diff --git a/crates/registry-casework/tests/review_http.rs b/crates/registry-casework/tests/review_http.rs index fd41de0d0d..7ea547e904 100644 --- a/crates/registry-casework/tests/review_http.rs +++ b/crates/registry-casework/tests/review_http.rs @@ -253,6 +253,7 @@ fn project(issuer: &str) -> CaseworkProject { queue: "review".to_owned(), deciding_profiles: vec!["staff".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: true, exclude_previous_stage_reviewers: true, @@ -291,6 +292,7 @@ fn project(issuer: &str) -> CaseworkProject { queue: "review".to_owned(), deciding_profiles: vec!["staff".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: true, exclude_previous_stage_reviewers: true, @@ -777,6 +779,82 @@ async fn create_task( (accepted, task_id) } +async fn claim_task_over_http( + app: &axum::Router, + task_id: Uuid, + token: &str, + idempotency_key: &str, +) { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v1/review-tasks/{task_id}/claim")) + .header("authorization", format!("Bearer {token}")) + .header(CASEWORK_PROFILE_HEADER, "staff") + .header("if-match", "\"1\"") + .header("idempotency-key", idempotency_key) + .body(Body::empty()) + .expect("claim review task"), + ) + .await + .expect("claim review task response"); + assert_eq!(response.status(), StatusCode::OK); +} + +async fn decide_task_over_http( + app: &axum::Router, + task_id: Uuid, + token: &str, + idempotency_key: &str, + decision: &Value, +) -> axum::response::Response { + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v1/review-tasks/{task_id}/decisions")) + .header("authorization", format!("Bearer {token}")) + .header(CASEWORK_PROFILE_HEADER, "staff") + .header(CONTENT_TYPE, "application/json") + .header("if-match", "\"2\"") + .header("idempotency-key", idempotency_key) + .body(Body::from( + serde_json::to_vec(decision).expect("serialize review decision"), + )) + .expect("decide review task"), + ) + .await + .expect("decide review task response") +} + +async fn review_result_over_http( + app: &axum::Router, + idp: &MockIdp, + request_id: Uuid, +) -> ReviewResult { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v1/review-requests/{request_id}/result")) + .header("authorization", format!("Bearer {}", token(idp))) + .header(REVIEW_PROFILE_HEADER, "producer") + .body(Body::empty()) + .expect("read review result"), + ) + .await + .expect("review result response"); + assert_eq!(response.status(), StatusCode::OK); + serde_json::from_slice( + &to_bytes(response.into_body(), 32 * 1024) + .await + .expect("bounded review result"), + ) + .expect("review result JSON") +} + fn assert_client_status(error: CaseworkClientError, expected: u16) { assert!( matches!(error, CaseworkClientError::Problem { status, .. } if status == expected), @@ -2907,6 +2985,172 @@ async fn standalone_structured_answer_can_be_claimed_decided_and_polled_over_htt idp.stop().await; } +#[tokio::test] +async fn stage_outcomes_allow_local_changes_request_but_refuse_it_at_final_over_http() { + let idp = MockIdp::start().await; + let mut staged_project = project(&idp.issuer()); + let policy = &mut staged_project.review_policies[0]; + policy.context = ReviewContextPolicy::Submitted; + policy.outcomes.extend([ + ReviewOutcomePolicy { + id: "needs-correction".to_owned(), + label: "Ask for changes".to_owned(), + settlement: ReviewOutcomeSettlement::ChangesRequested, + reason_required: true, + public_reason: registry_casework_core::ReviewPublicReasonRule::Optional, + result_required: false, + }, + ReviewOutcomePolicy { + id: "rejected".to_owned(), + label: "Reject".to_owned(), + settlement: ReviewOutcomeSettlement::Rejected, + reason_required: true, + public_reason: registry_casework_core::ReviewPublicReasonRule::Optional, + result_required: false, + }, + ]); + policy.stages[0].id = "local-review".to_owned(); + policy.stages[0].outcomes = Some(vec![ + "approved".to_owned(), + "needs-correction".to_owned(), + "rejected".to_owned(), + ]); + policy.stages[0].exclude_previous_stage_reviewers = false; + let mut final_stage = policy.stages[0].clone(); + final_stage.id = "final-approval".to_owned(); + final_stage.outcomes = Some(vec!["approved".to_owned(), "rejected".to_owned()]); + final_stage.exclude_previous_stage_reviewers = true; + policy.stages.push(final_stage); + staged_project.check().expect("stage-local outcome project"); + + let (app, _, _, _, _, _, database) = app_with_project(&idp, staged_project).await; + let reviewer_token = reviewer_token(&idp); + let ask_for_changes = json!({ + "decision": { + "type": "changes-requested", + "outcome": "needs-correction", + "reason": "The submitted address needs correction", + "publicReason": "Please correct the address" + } + }); + let approval = json!({ + "decision": {"type": "approve", "outcome": "approved"} + }); + + let mut local_request = review_request_for_subject( + "stage-local-return", + "stage-local-return-ref", + &idp.issuer(), + ); + local_request.context = ReviewContext::Submitted { + snapshot: json!({"summary": "Return the local correction"}), + }; + let (local_created, local_task) = create_task(&app, &database, &idp, &local_request).await; + claim_task_over_http( + &app, + local_task, + &reviewer_token, + "claim-stage-local-return", + ) + .await; + let local_decision = decide_task_over_http( + &app, + local_task, + &reviewer_token, + "decide-stage-local-return", + &ask_for_changes, + ) + .await; + assert_eq!(local_decision.status(), StatusCode::NO_CONTENT); + let local_result = review_result_over_http(&app, &idp, local_created.request_id).await; + assert_eq!(local_result.status, ReviewResultStatus::ChangesRequested); + assert_eq!(local_result.outcome.as_deref(), Some("needs-correction")); + + let mut final_request = local_request; + final_request.subject.version = "2".to_owned(); + final_request.subject.digest = ContentDigest::for_bytes(b"corrected submission"); + final_request.requester_reference = "stage-final-review-ref".to_owned(); + final_request.context = ReviewContext::Submitted { + snapshot: json!({"summary": "Complete the final review"}), + }; + let (final_created, first_task) = create_task(&app, &database, &idp, &final_request).await; + claim_task_over_http( + &app, + first_task, + &reviewer_token, + "claim-stage-before-final", + ) + .await; + let first_approval = decide_task_over_http( + &app, + first_task, + &reviewer_token, + "approve-stage-before-final", + &approval, + ) + .await; + assert_eq!(first_approval.status(), StatusCode::NO_CONTENT); + + let final_task: Uuid = database + .query_one( + "SELECT task_id FROM casework_review_tasks + WHERE request_id=$1 AND stage_index=1 ORDER BY slot LIMIT 1", + &[&final_created.request_id], + ) + .await + .expect("final approval task") + .get(0); + let colleague_token = colleague_token(&idp); + claim_task_over_http( + &app, + final_task, + &colleague_token, + "claim-stage-final-approval", + ) + .await; + let refused = decide_task_over_http( + &app, + final_task, + &colleague_token, + "refuse-stage-final-changes", + &ask_for_changes, + ) + .await; + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + assert_eq!(refused.headers()[VALIDATION_PATH_HEADER], "$.outcome"); + assert_eq!( + refused.headers()[VALIDATION_REASON_HEADER], + "outcome-not-declared" + ); + assert_eq!( + refused.headers()[VALIDATION_ALLOWED_HEADER], + "approved,rejected" + ); + let refused_problem = to_bytes(refused.into_body(), 16 * 1024) + .await + .expect("bounded unavailable-outcome problem"); + assert!( + !String::from_utf8_lossy(&refused_problem).contains("needs-correction"), + "the submitted unavailable outcome is not copied into the problem" + ); + + let approved = decide_task_over_http( + &app, + final_task, + &colleague_token, + "approve-stage-final", + &approval, + ) + .await; + assert_eq!(approved.status(), StatusCode::NO_CONTENT); + + let result = review_result_over_http(&app, &idp, final_created.request_id).await; + assert_eq!(result.status, ReviewResultStatus::Approved); + assert_eq!(result.outcome.as_deref(), Some("approved")); + + idp.stop().await; +} + #[tokio::test] async fn an_initiator_reads_only_the_requester_visible_history_of_their_own_request_over_http() { let idp = MockIdp::start().await; diff --git a/crates/registry-casework/tests/review_payment_fixture_postgres.rs b/crates/registry-casework/tests/review_payment_fixture_postgres.rs index 695bf7094c..1092acb443 100644 --- a/crates/registry-casework/tests/review_payment_fixture_postgres.rs +++ b/crates/registry-casework/tests/review_payment_fixture_postgres.rs @@ -359,6 +359,7 @@ fn project(issuer: &str) -> CaseworkProject { queue: "payment-review".to_owned(), deciding_profiles: vec!["reviewer".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: false, exclude_previous_stage_reviewers: false, diff --git a/crates/registry-casework/tests/review_postgres.rs b/crates/registry-casework/tests/review_postgres.rs index af9bedc626..36e7b73921 100644 --- a/crates/registry-casework/tests/review_postgres.rs +++ b/crates/registry-casework/tests/review_postgres.rs @@ -301,6 +301,7 @@ fn project(version: &str) -> CaseworkProject { queue: "review".to_owned(), deciding_profiles: vec!["staff".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: true, exclude_previous_stage_reviewers: false, @@ -311,6 +312,7 @@ fn project(version: &str) -> CaseworkProject { queue: "review".to_owned(), deciding_profiles: vec!["staff".to_owned()], required_approvals: 1, + outcomes: None, negative_decisions: ReviewNegativeDecisionMode::Settle, exclude_initiator: true, exclude_previous_stage_reviewers: true, diff --git a/crates/registry-caseworkctl/src/project.rs b/crates/registry-caseworkctl/src/project.rs index 5050e44950..9ab977f186 100644 --- a/crates/registry-caseworkctl/src/project.rs +++ b/crates/registry-caseworkctl/src/project.rs @@ -761,7 +761,9 @@ pub(super) fn explain(project: &Path) -> Result { "contextCustody": custody, "displaySchema": review.display_schema, "resultSchema": review.result_schema, - "stages": review.stages.iter().map(crate::report::ExplainStage::from).collect::>(), + "stages": review.stages.iter().map(|stage| { + crate::report::ExplainStage::effective(stage, &review.outcomes) + }).collect::>(), "outcomes": review.outcomes, "routing": review.routing, "routingFields": review.routing_fields(), diff --git a/crates/registry-caseworkctl/src/report.rs b/crates/registry-caseworkctl/src/report.rs index 4c2717899f..4ed3641f9c 100644 --- a/crates/registry-caseworkctl/src/report.rs +++ b/crates/registry-caseworkctl/src/report.rs @@ -277,19 +277,29 @@ payload!(ExplainStage { deciding_profiles: Vec, #[cfg_attr(feature = "schema", schemars(with = "registry_platform_yaml::BoundedU32<1, { registry_casework_core::MAXIMUM_REVIEW_APPROVALS_PER_STAGE as u32 }>"))] required_approvals: u16, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + outcomes: Vec, negative_decisions: registry_casework_core::ReviewNegativeDecisionMode, exclude_initiator: bool, exclude_previous_stage_reviewers: bool }); -impl From<&ReviewStagePolicy> for ExplainStage { - fn from(stage: &ReviewStagePolicy) -> Self { +impl ExplainStage { + pub(crate) fn effective( + stage: &ReviewStagePolicy, + policy_outcomes: &[ReviewOutcomePolicy], + ) -> Self { Self { id: stage.id.clone(), label: stage.label.clone(), queue: stage.queue.clone(), deciding_profiles: stage.deciding_profiles.clone(), required_approvals: stage.required_approvals, + outcomes: policy_outcomes + .iter() + .filter(|outcome| stage.offers_outcome(&outcome.id)) + .map(|outcome| outcome.id.clone()) + .collect(), negative_decisions: stage.negative_decisions, exclude_initiator: stage.exclude_initiator, exclude_previous_stage_reviewers: stage.exclude_previous_stage_reviewers, diff --git a/crates/registry-caseworkctl/tests/report_examples.rs b/crates/registry-caseworkctl/tests/report_examples.rs index 2a065a6755..c79bc7a381 100644 --- a/crates/registry-caseworkctl/tests/report_examples.rs +++ b/crates/registry-caseworkctl/tests/report_examples.rs @@ -14,6 +14,7 @@ use serde_json::Value; const STANDALONE: &str = "products/casework/examples/standalone-decision"; const ADVANCED: &str = "products/casework/examples/multi-stage-routing-clocks"; +const STAGE_OUTCOMES: &str = "products/casework/examples/stage-outcomes"; fn repo_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -43,7 +44,7 @@ fn stage_project(root: &Path, staging: &Path, project: &str) { fn output(root: &Path, arguments: &[&str], expected_exit: i32) -> (Value, Vec) { let scratch = tempfile::tempdir().expect("temporary directory"); - for project in [STANDALONE, ADVANCED] { + for project in [STANDALONE, ADVANCED, STAGE_OUTCOMES] { stage_project(root, scratch.path(), project); } let output = Command::new(env!("CARGO_BIN_EXE_caseworkctl")) @@ -168,4 +169,39 @@ fn explain_reports_policy_usage_and_project_level_resources() { assert!(unlabelled["reviewPolicies"][0]["stages"][0] .get("label") .is_none()); + assert_eq!( + unlabelled["reviewPolicies"][0]["stages"][0]["outcomes"], + serde_json::json!(["confirmed", "rejected"]), + "an omitted stage subset explains every policy outcome" + ); +} + +#[test] +fn explain_reports_each_stages_effective_outcomes() { + let root = repo_root(); + let (report, _) = output(&root, &["explain", STAGE_OUTCOMES], 0); + let stages = report["reviewPolicies"][0]["stages"] + .as_array() + .expect("stages are reported"); + + assert_eq!(stages[0]["label"], "Local review"); + assert_eq!( + stages[0]["outcomes"], + serde_json::json!(["approved", "changes-requested", "rejected"]) + ); + assert_eq!(stages[1]["label"], "Final review"); + assert_eq!( + stages[1]["outcomes"], + serde_json::json!(["approved", "rejected"]) + ); +} + +#[test] +fn stage_outcomes_example_passes_offline_authoring_commands() { + let root = repo_root(); + for command in ["check", "explain", "test"] { + let (report, _) = output(&root, &[command, STAGE_OUTCOMES], 0); + assert_eq!(report["command"], command); + assert_eq!(report["status"], "complete"); + } } diff --git a/crates/registry-cli-docs/catalog.json b/crates/registry-cli-docs/catalog.json index aaabaed2e6..bfa607f99b 100644 --- a/crates/registry-cli-docs/catalog.json +++ b/crates/registry-cli-docs/catalog.json @@ -3539,6 +3539,15 @@ "possible_values": [], "environment": null }, + { + "display": "--evaluation-date ", + "description": "Fixed trusted date exposed to today(), in YYYY-MM-DD form", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -3585,6 +3594,14 @@ "--input ", "--action " ] + }, + { + "kind": "requires-all", + "when": "--evaluation-date ", + "arguments": [ + "--input ", + "--action " + ] } ], "subcommands": [] diff --git a/crates/registry-cli-docs/src/lib.rs b/crates/registry-cli-docs/src/lib.rs index ba6660069f..44baf62258 100644 --- a/crates/registry-cli-docs/src/lib.rs +++ b/crates/registry-cli-docs/src/lib.rs @@ -739,6 +739,10 @@ mod tests { "--expect ", vec!["--action ", "--input "], ), + ( + "--evaluation-date ", + vec!["--action ", "--input "], + ), ] .into_iter() .map(|(when, arguments)| { diff --git a/crates/registry-coordinator/Cargo.toml b/crates/registry-coordinator/Cargo.toml index 40489a523a..dbe1d3b491 100644 --- a/crates/registry-coordinator/Cargo.toml +++ b/crates/registry-coordinator/Cargo.toml @@ -147,6 +147,7 @@ registry-platform-audit.workspace = true registry-platform-oidc.workspace = true registry-platform-authcommon.workspace = true registry-platform-buildinfo.workspace = true +registry-platform-calendar.workspace = true registry-platform-httpsec = { workspace = true, features = ["server"] } registry-platform-metrics.workspace = true jsonwebtoken.workspace = true diff --git a/crates/registry-coordinator/src/authoring.rs b/crates/registry-coordinator/src/authoring.rs index e0879a61ea..4787c53e77 100644 --- a/crates/registry-coordinator/src/authoring.rs +++ b/crates/registry-coordinator/src/authoring.rs @@ -301,6 +301,9 @@ impl NarrowedLimits { #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] struct AuthoredWorkflow { + /// Optional reviewed monthly starts keyed by stable trigger ID. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + calendar_triggers: BTreeMap, /// Optional authenticated product-event starts keyed by stable trigger ID. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] event_triggers: BTreeMap, @@ -308,6 +311,12 @@ struct AuthoredWorkflow { input: Schema, /// Maximum seconds from Run admission to expiry, from 1 to 31536000. deadline_seconds: BoundedU32<1, 31_536_000>, + /// Elapsed seconds from Run admission before a waiting Run takes onDeadline. + #[serde(default)] + service_deadline_seconds: Option>, + /// Entry to a separate path for a Run still waiting at its service deadline. + #[serde(default)] + on_deadline: Option, /// The stable Step ID every Run begins at. start: LocalId, /// Steps keyed by stable ID; map ordering has no execution meaning. @@ -316,6 +325,24 @@ struct AuthoredWorkflow { outcomes: BTreeMap, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +struct AuthoredCalendarTrigger { + /// Five-field cron restricted to `MIN HOUR 1|L * *` in Version 1. + cron: String, + /// Named IANA timezone used to interpret the local month, day and time. + timezone: String, + /// Optional human label; it changes neither stable identity nor behavior revision. + #[serde(default, deserialize_with = "optional_display_text")] + #[cfg_attr(feature = "schema", schemars(with = "Option"))] + label: Option, + /// Optional explanation for readers; it has no execution or authorization effect. + #[serde(default, deserialize_with = "optional_display_text")] + #[cfg_attr(feature = "schema", schemars(with = "Option"))] + description: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -555,6 +582,11 @@ enum AuthoredStep { input: AuthoredMapping, /// The next Step on the successful completion route. next: LocalId, + /// A separate Step taken only after a read or evaluation exhausts + /// retries for a definite availability failure. It receives no Step + /// output; the bounded category remains run status. + #[serde(default)] + on_failure: Option, /// A separate Step taken only for a validated package-declared business refusal. /// Omit it to fail the run with `product-refused` on a declared refusal too. #[serde(default)] @@ -940,6 +972,13 @@ fn project_integrity_diagnostics( "coordinator.project.operation-id-collision", map_ids("/operations", &project.operations), ), + ( + "coordinator.project.calendar-trigger-id-collision", + map_ids( + "/workflow/calendarTriggers", + &project.workflow.calendar_triggers, + ), + ), ( "coordinator.project.event-trigger-id-collision", map_ids("/workflow/eventTriggers", &project.workflow.event_triggers), @@ -1446,6 +1485,37 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { ); } let input = resolver.resolve(&project.workflow.input.0.into_value(), "/workflow/input")?; + let mut calendar_trigger_presentation = BTreeMap::new(); + let calendar_triggers = project + .workflow + .calendar_triggers + .into_iter() + .map(|(id, trigger)| { + let id = id.into_string(); + let compiled = crate::calendar_trigger::CalendarTrigger { + cron: trigger.cron, + timezone: trigger.timezone, + }; + compiled.schedule().map_err(|error| { + CoordinatorError::new("definition.calendar-trigger", error.to_string()) + .at( + "workflow.yaml", + format!("/workflow/calendarTriggers/{id}"), + ) + .suggest("Use `MIN HOUR 1 * *` or `MIN HOUR L * *` with a named IANA timezone such as Asia/Bangkok.") + })?; + if trigger.label.is_some() || trigger.description.is_some() { + calendar_trigger_presentation.insert( + id.clone(), + crate::definition::TriggerPresentation { + label: trigger.label, + description: trigger.description, + }, + ); + } + Ok((id, compiled)) + }) + .collect::>>()?; let mut trigger_presentation = BTreeMap::new(); let event_triggers = project .workflow @@ -1502,6 +1572,7 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { label: project.label, description: project.description, steps: BTreeMap::new(), + calendar_triggers: calendar_trigger_presentation, event_triggers: trigger_presentation, }; for (id, step) in project.workflow.steps { @@ -1552,6 +1623,7 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { operation, input, next, + on_failure, on_declared_refusal, label, description, @@ -1570,6 +1642,7 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { }, input: input.into(), next: next.into_string(), + on_failure: on_failure.map(LocalId::into_string), on_declared_refusal: on_declared_refusal.map(LocalId::into_string), }, label, @@ -1625,6 +1698,23 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { _ => None, }) .collect(); + let service_deadline = match ( + project.workflow.service_deadline_seconds, + project.workflow.on_deadline, + ) { + (Some(seconds), Some(next)) => Some(definition::ServiceDeadline { + seconds: seconds.get(), + next: next.into_string(), + }), + (None, None) => None, + _ => { + return Err(CoordinatorError::new( + "definition.deadline", + "Set serviceDeadlineSeconds and onDeadline together.", + ) + .at("workflow.yaml", "onDeadline")) + } + }; Ok(Workflow { api_version: definition::API_VERSION.into(), kind: "Workflow".into(), @@ -1639,9 +1729,11 @@ fn compile(project: CoordinatorProject, path: &Path) -> Result { presentation, references: BTreeMap::new(), deadline: format!("{}s", project.workflow.deadline_seconds.get()), + service_deadline, start: project.workflow.start.into_string(), steps, outcomes, + calendar_triggers, event_triggers, }) } @@ -2305,6 +2397,8 @@ pub(crate) fn positioned(document: &Document, error: CoordinatorError) -> Coordi "/version" => "/project/version".into(), "/deadline" => "/workflow/deadlineSeconds".into(), + "/serviceDeadlineSeconds" => "/workflow/serviceDeadlineSeconds".into(), + "/onDeadline" => "/workflow/onDeadline".into(), _ => { let mut parts = pointer.split('/').map(str::to_owned).collect::>(); if parts.len() > 3 && parts[1] == "steps" { @@ -2379,6 +2473,7 @@ pub fn project_schema() -> std::result::Result { ("/properties/functions", 0, 128), ("/properties/types", 0, 128), ("/$defs/AuthoredWorkflow/properties/eventTriggers", 0, 16), + ("/$defs/AuthoredWorkflow/properties/calendarTriggers", 0, 1), ("/$defs/AuthoredWorkflow/properties/steps", 1, 64), ("/$defs/AuthoredWorkflow/properties/outcomes", 1, 64), ] { diff --git a/crates/registry-coordinator/src/calendar_trigger.rs b/crates/registry-coordinator/src/calendar_trigger.rs new file mode 100644 index 0000000000..57a49d981b --- /dev/null +++ b/crates/registry-coordinator/src/calendar_trigger.rs @@ -0,0 +1,534 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Pure evaluation of the deliberately narrow Version 1 calendar trigger. + +use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, SecondsFormat, Utc}; +use registry_platform_calendar::{local_date, resolve_local_instant, CalendarEvaluationError}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, PoisonError, + }, + time::{Duration, Instant}, +}; + +use crate::{ + deployment::Workflows, runtime::RuntimeConfig, store::Store, CoordinatorError, + Result as CoordinatorResult, +}; + +const CALENDAR_SWEEP_TIMEOUT: Duration = Duration::from_millis(250); + +/// A calendar expression is outside the reviewed first slice, names an +/// unknown timezone, or cannot be evaluated inside Chrono's date range. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum CalendarTriggerError { + #[error("calendar cron must be `MIN HOUR 1|L * *`")] + InvalidExpression, + #[error("calendar timezone must be a named IANA timezone")] + InvalidTimezone, + #[error("calendar evaluation overflowed the supported date range")] + Overflow, +} + +/// Reviewed calendar trigger content retained in the Workflow snapshot. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CalendarTrigger { + pub cron: String, + pub timezone: String, +} + +impl CalendarTrigger { + pub fn schedule(&self) -> Result { + CalendarSchedule::parse(&self.cron, &self.timezone) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MonthDay { + First, + Last, +} + +/// One validated monthly schedule. Version 1 accepts exactly one local time +/// on the first or last day of every month. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CalendarSchedule { + minute: u32, + hour: u32, + day: MonthDay, + timezone: String, +} + +/// One authored local occurrence and the exact instant selected for it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CalendarOccurrence { + pub scheduled_at: DateTime, + pub local_date: NaiveDate, +} + +impl CalendarOccurrence { + /// Calendar admission exposes only the occurrence instant to the + /// workflow. Catch-up and cursor details remain Coordinator state. + pub fn input(&self) -> Value { + json!({ + "scheduledAt": self + .scheduled_at + .to_rfc3339_opts(SecondsFormat::Secs, true) + }) + } +} + +/// Latest-only catch-up result. `skipped` excludes the returned occurrence. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CalendarCatchUp { + pub latest: Option, + pub skipped: u64, +} + +/// Stable data for `coordinatorctl explain`; it is calculated by the same +/// path as admission rather than by a second cron interpretation. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CalendarNextExplanation { + pub expression: String, + pub timezone: String, + pub after: DateTime, + pub next_scheduled_at: DateTime, + pub next_local_date: NaiveDate, + pub next_local_time: String, +} + +/// The serving process's small calendar-admission sweep. PostgreSQL remains +/// the durable clock and replica fence; this one-second process gate only +/// avoids opening a cursor transaction on every ordinary worker poll. +pub struct CalendarRunner { + next_poll: Mutex, + sweep_cursor: FairSweepCursor, +} + +/// In-memory ordering state for bounded calendar sweeps. The durable cursor +/// and admission transaction remain in PostgreSQL. +#[derive(Default)] +struct FairSweepCursor { + next_workflow: AtomicUsize, +} + +impl FairSweepCursor { + fn order(&self, workflow_count: usize) -> Vec { + if workflow_count == 0 { + return Vec::new(); + } + let start = self.next_workflow.load(Ordering::Relaxed) % workflow_count; + (0..workflow_count) + .map(|offset| (start + offset) % workflow_count) + .collect() + } + + fn advance_after(&self, attempted: usize, workflow_count: usize) { + if workflow_count != 0 { + self.next_workflow + .store((attempted + 1) % workflow_count, Ordering::Relaxed); + } + } +} + +impl Default for CalendarRunner { + fn default() -> Self { + Self { + next_poll: Mutex::new(Instant::now()), + sweep_cursor: FairSweepCursor::default(), + } + } +} + +impl CalendarRunner { + pub async fn tick( + &self, + store: &Store, + workflows: &Workflows, + runtime: &RuntimeConfig, + ) -> CoordinatorResult { + { + let now = Instant::now(); + let mut next = self + .next_poll + .lock() + .unwrap_or_else(PoisonError::into_inner); + if now < *next { + return Ok(false); + } + *next = now + Duration::from_secs(1); + } + tokio::time::timeout( + CALENDAR_SWEEP_TIMEOUT, + self.sweep(store, workflows, runtime), + ) + .await + .map_err(|_| { + CoordinatorError::new( + "calendar-sweep-timeout", + "calendar admission sweep exceeded its bounded execution time", + ) + })? + } + + async fn sweep( + &self, + store: &Store, + workflows: &Workflows, + runtime: &RuntimeConfig, + ) -> CoordinatorResult { + let mut admitted = false; + let eligible = workflows + .iter() + .filter(|(_, package)| !package.definition.workflow.calendar_triggers.is_empty()) + .collect::>(); + for index in self.sweep_cursor.order(eligible.len()) { + // Advance before awaiting admission so timeout cancellation cannot + // leave the same sorted workflow permanently at the head. + self.sweep_cursor.advance_after(index, eligible.len()); + let (_, package) = eligible[index]; + for trigger_id in package.definition.workflow.calendar_triggers.keys() { + admitted |= store + .admit_calendar_due(&package.definition, trigger_id, runtime) + .await? + .is_some(); + } + } + Ok(admitted) + } +} + +impl CalendarSchedule { + pub fn parse(expression: &str, timezone: &str) -> Result { + let fields = expression.split_whitespace().collect::>(); + if fields.len() != 5 || fields[3] != "*" || fields[4] != "*" { + return Err(CalendarTriggerError::InvalidExpression); + } + let minute = decimal_field(fields[0], 59)?; + let hour = decimal_field(fields[1], 23)?; + let day = match fields[2] { + "1" => MonthDay::First, + "L" => MonthDay::Last, + _ => return Err(CalendarTriggerError::InvalidExpression), + }; + local_date(DateTime::::UNIX_EPOCH, timezone).map_err(platform_error)?; + Ok(Self { + minute, + hour, + day, + timezone: timezone.to_owned(), + }) + } + + pub fn expression(&self) -> String { + format!( + "{} {} {} * *", + self.minute, + self.hour, + match self.day { + MonthDay::First => "1", + MonthDay::Last => "L", + } + ) + } + + /// Return the first occurrence strictly after `after`. + pub fn next_after( + &self, + after: DateTime, + ) -> Result { + let local = local_date(after, &self.timezone).map_err(platform_error)?; + let candidate = self.occurrence(local.year(), local.month())?; + if candidate.scheduled_at > after { + return Ok(candidate); + } + let (year, month) = shift_month(local.year(), local.month(), 1)?; + self.occurrence(year, month) + } + + /// Evaluate all occurrences after a durable cursor through `through`, but + /// retain only the latest due occurrence. The count records every older + /// due occurrence deliberately not admitted. + pub fn latest_since( + &self, + cursor: DateTime, + through: DateTime, + ) -> Result { + if cursor >= through { + return Ok(CalendarCatchUp::default()); + } + let first = self.next_after(cursor)?; + if first.scheduled_at > through { + return Ok(CalendarCatchUp::default()); + } + let latest = self.latest_at_or_before(through)?; + if latest.scheduled_at < first.scheduled_at { + return Ok(CalendarCatchUp::default()); + } + Ok(CalendarCatchUp { + skipped: month_distance(first.local_date, latest.local_date)?, + latest: Some(latest), + }) + } + + pub fn explain_next_after( + &self, + after: DateTime, + ) -> Result { + let next = self.next_after(after)?; + Ok(CalendarNextExplanation { + expression: self.expression(), + timezone: self.timezone.clone(), + after, + next_scheduled_at: next.scheduled_at, + next_local_date: next.local_date, + next_local_time: format!("{:02}:{:02}", self.hour, self.minute), + }) + } + + fn latest_at_or_before( + &self, + through: DateTime, + ) -> Result { + let local = local_date(through, &self.timezone).map_err(platform_error)?; + let candidate = self.occurrence(local.year(), local.month())?; + if candidate.scheduled_at <= through { + return Ok(candidate); + } + let (year, month) = shift_month(local.year(), local.month(), -1)?; + self.occurrence(year, month) + } + + fn occurrence( + &self, + year: i32, + month: u32, + ) -> Result { + let first = + NaiveDate::from_ymd_opt(year, month, 1).ok_or(CalendarTriggerError::Overflow)?; + let local_date = match self.day { + MonthDay::First => first, + MonthDay::Last => { + let (next_year, next_month) = shift_month(year, month, 1)?; + NaiveDate::from_ymd_opt(next_year, next_month, 1) + .and_then(|date| date.pred_opt()) + .ok_or(CalendarTriggerError::Overflow)? + } + }; + let local_time = NaiveTime::from_hms_opt(self.hour, self.minute, 0) + .ok_or(CalendarTriggerError::InvalidExpression)?; + let scheduled_at = resolve_local_instant(local_date, local_time, &self.timezone) + .map_err(platform_error)?; + Ok(CalendarOccurrence { + scheduled_at, + local_date, + }) + } +} + +fn decimal_field(value: &str, maximum: u32) -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(CalendarTriggerError::InvalidExpression); + } + let value = value + .parse::() + .map_err(|_| CalendarTriggerError::InvalidExpression)?; + (value <= maximum) + .then_some(value) + .ok_or(CalendarTriggerError::InvalidExpression) +} + +fn shift_month(year: i32, month: u32, amount: i64) -> Result<(i32, u32), CalendarTriggerError> { + let month = i64::from(month) + .checked_sub(1) + .ok_or(CalendarTriggerError::Overflow)?; + let index = i64::from(year) + .checked_mul(12) + .and_then(|value| value.checked_add(month)) + .and_then(|value| value.checked_add(amount)) + .ok_or(CalendarTriggerError::Overflow)?; + let shifted_year = + i32::try_from(index.div_euclid(12)).map_err(|_| CalendarTriggerError::Overflow)?; + let shifted_month = + u32::try_from(index.rem_euclid(12) + 1).map_err(|_| CalendarTriggerError::Overflow)?; + Ok((shifted_year, shifted_month)) +} + +fn month_distance(from: NaiveDate, to: NaiveDate) -> Result { + let years = i64::from(to.year()) + .checked_sub(i64::from(from.year())) + .and_then(|value| value.checked_mul(12)) + .ok_or(CalendarTriggerError::Overflow)?; + let months = years + .checked_add(i64::from(to.month())) + .and_then(|value| value.checked_sub(i64::from(from.month()))) + .ok_or(CalendarTriggerError::Overflow)?; + u64::try_from(months).map_err(|_| CalendarTriggerError::Overflow) +} + +fn platform_error(error: CalendarEvaluationError) -> CalendarTriggerError { + match error { + CalendarEvaluationError::InvalidTimezone => CalendarTriggerError::InvalidTimezone, + _ => CalendarTriggerError::Overflow, + } +} + +#[cfg(test)] +mod tests { + use chrono::{DateTime, Utc}; + use serde_json::json; + + use super::*; + + fn instant(value: &str) -> DateTime { + value.parse().expect("a valid RFC 3339 instant") + } + + #[test] + fn only_the_reviewed_monthly_cron_subset_is_accepted() { + let first = CalendarSchedule::parse("15 9 1 * *", "Asia/Bangkok").unwrap(); + assert_eq!(first.expression(), "15 9 1 * *"); + let last = CalendarSchedule::parse("0 17 L * *", "Europe/Paris").unwrap(); + assert_eq!(last.expression(), "0 17 L * *"); + + for expression in [ + "15 9 2 * *", + "15 9 * * *", + "15 9 1 1 *", + "15 9 1 * MON", + "* 9 1 * *", + "15 * 1 * *", + "60 9 1 * *", + "15 24 1 * *", + "15 9 1 *", + ] { + assert!( + CalendarSchedule::parse(expression, "UTC").is_err(), + "{expression} must remain outside the first slice" + ); + } + assert_eq!( + CalendarSchedule::parse("15 9 1 * *", "Not/A_Zone"), + Err(CalendarTriggerError::InvalidTimezone) + ); + } + + #[test] + fn first_and_last_day_schedules_resolve_in_the_named_zone() { + let first = CalendarSchedule::parse("15 9 1 * *", "Asia/Bangkok").unwrap(); + assert_eq!( + first + .next_after(instant("2026-01-31T17:00:00Z")) + .unwrap() + .scheduled_at, + instant("2026-02-01T02:15:00Z") + ); + + let last = CalendarSchedule::parse("0 17 L * *", "Europe/Paris").unwrap(); + let leap = last.next_after(instant("2028-02-01T00:00:00Z")).unwrap(); + assert_eq!(leap.local_date.to_string(), "2028-02-29"); + assert_eq!(leap.scheduled_at, instant("2028-02-29T16:00:00Z")); + } + + #[test] + fn a_fold_produces_one_earlier_instant_and_an_exclusive_successor() { + let schedule = CalendarSchedule::parse("30 1 1 * *", "America/New_York").unwrap(); + let folded = schedule + .next_after(instant("2026-10-01T06:00:00Z")) + .unwrap(); + assert_eq!(folded.scheduled_at, instant("2026-11-01T05:30:00Z")); + assert_eq!( + schedule + .next_after(folded.scheduled_at) + .unwrap() + .scheduled_at, + instant("2026-12-01T06:30:00Z") + ); + } + + #[test] + fn catch_up_selects_only_the_latest_due_instant_and_counts_every_skipped_one() { + let schedule = CalendarSchedule::parse("0 9 1 * *", "UTC").unwrap(); + let caught_up = schedule + .latest_since( + instant("2026-01-01T09:00:00Z"), + instant("2026-04-15T12:00:00Z"), + ) + .unwrap(); + assert_eq!(caught_up.skipped, 2); + assert_eq!( + caught_up.latest.unwrap().scheduled_at, + instant("2026-04-01T09:00:00Z") + ); + + assert_eq!( + schedule + .latest_since( + instant("2026-04-01T09:00:00Z"), + instant("2026-04-30T23:59:59Z") + ) + .unwrap(), + CalendarCatchUp::default() + ); + assert_eq!( + schedule + .latest_since( + instant("2026-05-01T09:00:00Z"), + instant("2026-05-01T08:59:59Z") + ) + .unwrap(), + CalendarCatchUp::default() + ); + } + + #[test] + fn an_occurrence_input_contains_scheduled_at_and_nothing_else() { + let occurrence = CalendarSchedule::parse("0 9 1 * *", "UTC") + .unwrap() + .next_after(instant("2026-01-01T09:00:00Z")) + .unwrap(); + assert_eq!( + occurrence.input(), + json!({"scheduledAt":"2026-02-01T09:00:00Z"}) + ); + } + + #[test] + fn explain_is_stable_data_derived_from_the_same_next_occurrence() { + let schedule = CalendarSchedule::parse("5 8 L * *", "Asia/Bangkok").unwrap(); + let explanation = schedule + .explain_next_after(instant("2026-01-10T00:00:00Z")) + .unwrap(); + assert_eq!(explanation.expression, "5 8 L * *"); + assert_eq!(explanation.timezone, "Asia/Bangkok"); + assert_eq!(explanation.after, instant("2026-01-10T00:00:00Z")); + assert_eq!( + explanation.next_scheduled_at, + instant("2026-01-31T01:05:00Z") + ); + assert_eq!(explanation.next_local_date.to_string(), "2026-01-31"); + assert_eq!(explanation.next_local_time, "08:05"); + } + + #[test] + fn a_cutoff_rotates_the_next_sweep_to_the_earliest_unattempted_workflow() { + let cursor = FairSweepCursor::default(); + + assert!(cursor.order(0).is_empty()); + assert_eq!(cursor.order(3), [0, 1, 2]); + + // The deadline cuts the first sweep off after alpha starts. Advancing + // before its await makes beta the deterministic head of the next one. + cursor.advance_after(0, 3); + assert_eq!(cursor.order(3), [1, 2, 0]); + + // A second cutoff still advances rather than pinning the sorted head. + cursor.advance_after(1, 3); + assert_eq!(cursor.order(3), [2, 0, 1]); + } +} diff --git a/crates/registry-coordinator/src/cli.rs b/crates/registry-coordinator/src/cli.rs index ec71d9c6c5..ce6b1b2060 100644 --- a/crates/registry-coordinator/src/cli.rs +++ b/crates/registry-coordinator/src/cli.rs @@ -1391,7 +1391,7 @@ fn render_human(cli: &Cli, value: &Value) -> String { } } -/// One line per step in graph order, the event triggers, then execution bounds. +/// One line per step in graph order, the triggers, then execution bounds. fn render_explain(value: &Value) -> String { let listed = |values: &Value| { values @@ -1403,13 +1403,20 @@ fn render_explain(value: &Value) -> String { .join(", ") }; let mut out = format!( - "Workflow {} version {}\nDefinition: {}\nDeadline: {} seconds; outcomes: {}\nSteps from start:\n", + "Workflow {} version {}\nDefinition: {}\nDeadline: {} seconds; outcomes: {}\n", text(&value["workflow"]), text(&value["version"]), text(&value["workflowRevision"]), value["deadlineSeconds"], listed(&value["outcomes"]) ); + if let Some(target) = value["onDeadline"].as_str() { + out.push_str(&format!( + "Service deadline in {} seconds routes to {target} while waiting.\n", + value["serviceDeadlineSeconds"] + )); + } + out.push_str("Steps from start:\n"); for step in value["steps"].as_array().into_iter().flatten() { let next = listed(&step["next"]); let line = match step["kind"].as_str().unwrap_or_default() { @@ -1456,7 +1463,24 @@ fn render_explain(value: &Value) -> String { ), _ => format!("finish {}", text(&step["outcome"])), }; - out.push_str(&format!(" {}: {line}\n", text(&step["id"]))); + out.push_str(&format!(" {}: {line}", text(&step["id"]))); + if let Some(target) = step["onFailure"].as_str() { + out.push_str(&format!(", on exhausted availability failure {target}")); + } + out.push('\n'); + } + for (id, trigger) in value["calendarTriggers"].as_object().into_iter().flatten() { + out.push_str(&format!( + "Calendar trigger {id}: {} in {}\n", + text(&trigger["cron"]), + text(&trigger["timezone"]) + )); + let wording = &value["presentation"]["calendarTriggers"][id]; + for member in ["label", "description"] { + if let Some(line) = wording[member].as_str() { + out.push_str(&format!(" {member}: {line}\n")); + } + } } for (id, trigger) in value["eventTriggers"].as_object().into_iter().flatten() { out.push_str(&format!( diff --git a/crates/registry-coordinator/src/decision.rs b/crates/registry-coordinator/src/decision.rs index 078efc2050..c75a87f5c5 100644 --- a/crates/registry-coordinator/src/decision.rs +++ b/crates/registry-coordinator/src/decision.rs @@ -346,8 +346,8 @@ impl DecisionConnection { BearerToken::new(text.to_owned()).map_err(|_| ()) }) .await - .map_err(|_| unavailable())? - .map_err(|_| unavailable())?; + .map_err(|_| credential_unavailable())? + .map_err(|_| credential_unavailable())?; request = request.header( reqwest::header::AUTHORIZATION, token.authorization_header_value(), @@ -760,3 +760,9 @@ fn unavailable() -> CallOutcome { retry_after: None, } } +fn credential_unavailable() -> CallOutcome { + CallOutcome::Retryable { + code: "credential-unavailable".into(), + retry_after: None, + } +} diff --git a/crates/registry-coordinator/src/definition.rs b/crates/registry-coordinator/src/definition.rs index e041842ae4..8f6282e29e 100644 --- a/crates/registry-coordinator/src/definition.rs +++ b/crates/registry-coordinator/src/definition.rs @@ -76,13 +76,24 @@ pub struct Workflow { pub presentation: Presentation, pub references: BTreeMap, pub deadline: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_deadline: Option, pub start: String, pub steps: BTreeMap, pub outcomes: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub calendar_triggers: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub event_triggers: BTreeMap, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ServiceDeadline { + pub seconds: u32, + pub next: String, +} + /// How a step computes its value: a pure function over references, an /// object built member by member from references and nested objects, or one /// reference. @@ -280,6 +291,8 @@ pub struct Presentation { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub steps: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub calendar_triggers: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub event_triggers: BTreeMap, } @@ -430,6 +443,8 @@ pub enum Step { call: Call, input: Mapping, next: String, + #[serde(rename = "onFailure", default, skip_serializing_if = "Option::is_none")] + on_failure: Option, #[serde( rename = "onDeclaredRefusal", default, @@ -456,9 +471,11 @@ impl Step { Self::WaitUntil { next, .. } => vec![("next", next)], Self::Call { next, + on_failure, on_declared_refusal, .. } => std::iter::once(("next", next.as_str())) + .chain(on_failure.iter().map(|step| ("onFailure", step.as_str()))) .chain( on_declared_refusal .iter() @@ -768,6 +785,20 @@ impl Definition { let input_schema = compile_schema(&frozen.workflow.input) .map_err(|error| findings.push(error.at("workflow.yaml", "input"))) .ok(); + if !frozen.workflow.calendar_triggers.is_empty() + && input_schema.as_ref().is_some_and(|schema| { + !schema.is_valid(&serde_json::json!({ + "scheduledAt":"2000-01-01T00:00:00Z" + })) + }) + { + findings.push(authored( + "definition.calendar-trigger-input", + "input", + "calendar admission supplies exactly one scheduledAt date-time", + "Make the Workflow input accept an object containing only required scheduledAt with string format date-time.", + )); + } let mut outcome_schemas = BTreeMap::new(); for (id, schema) in &frozen.workflow.outcomes { match compile_schema(schema) { @@ -910,6 +941,7 @@ impl Definition { match step { Step::Call { call, + on_failure, on_declared_refusal, .. } => { @@ -920,6 +952,9 @@ impl Definition { item["mutating"] = serde_json::json!(call.operation.is_mutating()); item["effect"] = serde_json::json!(call.operation.descriptor().effect); item["recovery"] = serde_json::json!(call.operation.recovery()); + if let Some(target) = on_failure { + item["onFailure"] = serde_json::json!(target); + } if let Some(target) = on_declared_refusal { item["onDeclaredRefusal"] = serde_json::json!(target); } @@ -957,14 +992,17 @@ impl Definition { item }) .collect::>(); - Ok( - serde_json::json!({"workflow":self.workflow.id,"version":self.workflow.version,"workflowRevision":self.digest, + let mut report = 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,"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, + "contracts":contracts,"operations":self.workflow.operations,"operationContracts":operation_contracts,"calendarTriggers":self.workflow.calendar_triggers,"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."}), - ) + "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."}); + if let Some(deadline) = &self.workflow.service_deadline { + report["serviceDeadlineSeconds"] = serde_json::json!(deadline.seconds); + report["onDeadline"] = serde_json::json!(deadline.next); + } + Ok(report) } pub fn snapshot(&self) -> Result { @@ -1229,6 +1267,21 @@ impl Definition { .ok_or_else(|| fail("definition.deadline")) } + pub(crate) fn service_deadline_at( + &self, + admitted_at: DateTime, + ) -> Result>> { + self.workflow + .service_deadline + .as_ref() + .map(|deadline| { + admitted_at + .checked_add_signed(Duration::seconds(i64::from(deadline.seconds))) + .ok_or_else(|| fail("definition.deadline")) + }) + .transpose() + } + pub fn initial_due( &self, input: &Value, @@ -1430,6 +1483,14 @@ fn semantic_revision(snapshot: &Snapshot) -> Result { semantic["eventTriggers"] = serde_json::to_value(&flow.event_triggers).map_err(|_| fail("definition.canonical"))?; } + if !flow.calendar_triggers.is_empty() { + semantic["calendarTriggers"] = serde_json::to_value(&flow.calendar_triggers) + .map_err(|_| fail("definition.canonical"))?; + } + if let Some(deadline) = &flow.service_deadline { + semantic["serviceDeadline"] = + serde_json::to_value(deadline).map_err(|_| fail("definition.canonical"))?; + } let bytes = registry_platform_canonical_json::canonicalize_json(&semantic) .map_err(|_| fail("definition.canonical"))?; Ok(sha256_uri(&bytes)) @@ -1561,6 +1622,24 @@ pub(crate) fn offered(product: &str) -> String { /// step, since reachability and cycles mean nothing across a broken edge. fn workflow_findings(flow: &Workflow) -> Vec { let mut findings = crate::event_trigger::workflow_findings(flow); + if flow.calendar_triggers.len() > 1 { + findings.push(authored( + "definition.calendar-trigger", + "calendarTriggers", + "expected at most one calendar trigger", + "Keep one reviewed calendar trigger in a Workflow; use separate Workflows for different schedules.", + )); + } + for (name, trigger) in &flow.calendar_triggers { + if !valid_name(name) || trigger.schedule().is_err() { + findings.push(authored( + "definition.calendar-trigger", + &format!("calendarTriggers.{name}"), + "calendar trigger must name a valid local ID, `MIN HOUR 1|L * *`, and a named IANA timezone", + "Use one monthly first-day or last-day cron expression and an IANA timezone such as Asia/Bangkok.", + )); + } + } for (field, size, minimum, maximum) in [ ("steps", flow.steps.len(), 1, 64), ("connections", flow.connections.len(), 0, 16), @@ -1601,8 +1680,32 @@ fn workflow_findings(flow: &Workflow) -> Vec { if flow.api_version != API_VERSION || flow.kind != "Workflow" || !valid_name(&flow.id) { findings.push(fail("definition.shape")); } - if let Err(error) = parse_duration(&flow.deadline) { - findings.push(error.at("workflow.yaml", "deadline")); + match parse_duration(&flow.deadline) { + Err(error) => findings.push(error.at("workflow.yaml", "deadline")), + Ok(hard) + if flow.service_deadline.as_ref().is_some_and(|deadline| { + deadline.seconds == 0 || i64::from(deadline.seconds) >= hard.num_seconds() + }) => + { + findings.push(authored( + "definition.deadline", + "serviceDeadlineSeconds", + "The service deadline must come before the run deadline.", + "Use a positive serviceDeadlineSeconds shorter than deadlineSeconds.", + )); + } + Ok(_) => {} + } + if let Some(deadline) = &flow.service_deadline { + if !flow.steps.contains_key(&deadline.next) { + broken = true; + findings.push(authored( + "definition.deadline", + "onDeadline", + "The service deadline names an undeclared step.", + &format!("Set onDeadline to one of the declared steps: {steps}."), + )); + } } for (name, kind) in &flow.connections { if !valid_name(name) @@ -1748,6 +1851,7 @@ fn workflow_findings(flow: &Workflow) -> Vec { } => findings.extend(wait_findings(flow, name, wait_for, *timeout_seconds)), Step::Call { call, + on_failure, on_declared_refusal, .. } => { @@ -1798,6 +1902,17 @@ fn workflow_findings(flow: &Workflow) -> Vec { "Remove onDeclaredRefusal, or use it on an invoke-breg-action call.", )); } + if on_failure.is_some() && !call.operation.permits_failure_route() { + findings.push(authored( + "definition.operation", + &format!("steps.{name}.onFailure"), + &format!( + "operation {} may have a product effect and cannot route an operational failure", + call.operation.as_str() + ), + "Remove onFailure, or use it only on a read or evaluation Operation.", + )); + } if call.operation == Operation::ReadInForce && on_declared_refusal.is_none() { findings.push(authored( "definition.operation", @@ -1932,6 +2047,9 @@ fn graph_order(flow: &Workflow) -> Vec<&str> { } let mut order = Vec::with_capacity(flow.steps.len()); let mut ready = std::collections::VecDeque::from([flow.start.as_str()]); + if let Some(deadline) = &flow.service_deadline { + ready.push_back(deadline.next.as_str()); + } while let Some(name) = ready.pop_front() { order.push(name); for next in flow.steps[name].targets() { @@ -1961,8 +2079,16 @@ pub(crate) enum OutputRoute { pub(crate) type AvailableOutputs = BTreeMap; fn reachable_steps(flow: &Workflow) -> BTreeSet<&str> { - let mut reachable = BTreeSet::from([flow.start.as_str()]); - let mut frontier = vec![flow.start.as_str()]; + let mut reachable = reachable_from(flow, &flow.start); + if let Some(deadline) = &flow.service_deadline { + reachable.extend(reachable_from(flow, &deadline.next)); + } + reachable +} + +fn reachable_from<'a>(flow: &'a Workflow, start: &'a str) -> BTreeSet<&'a str> { + let mut reachable = BTreeSet::from([start]); + let mut frontier = vec![start]; while let Some(name) = frontier.pop() { for next in flow.steps[name].targets() { if reachable.insert(next) { @@ -1998,11 +2124,10 @@ fn propagated_outputs<'a>( .iter() .map(|name| (*name, predecessors(name).len())) .collect::>(); - let mut ready = if pending[flow.start.as_str()] == 0 { - vec![flow.start.as_str()] - } else { - vec![] - }; + let mut ready = pending + .iter() + .filter_map(|(&step, &count)| (count == 0).then_some(step)) + .collect::>(); let mut available: BTreeMap<&str, AvailableOutputs> = BTreeMap::new(); while let Some(name) = ready.pop() { let outputs = predecessors(name) @@ -2060,6 +2185,18 @@ pub(crate) fn output_routes(flow: &Workflow) -> BTreeMap(flow: &'a Workflow, incoming: &Incoming<'a>) -> Vec { let mut findings = Vec::new(); + if let Some(deadline) = &flow.service_deadline { + let ordinary = reachable_from(flow, &flow.start); + let late = reachable_from(flow, &deadline.next); + if !ordinary.is_disjoint(&late) { + findings.push(authored( + "definition.deadline", + "onDeadline", + "The service deadline path overlaps the ordinary workflow path.", + "Give the deadline path its own steps. It can run before any ordinary step output is available.", + )); + } + } let reachable = reachable_steps(flow); for name in flow.steps.keys() { if !reachable.contains(name.as_str()) { diff --git a/crates/registry-coordinator/src/deployment.rs b/crates/registry-coordinator/src/deployment.rs index 88b94a27d9..485962bddb 100644 --- a/crates/registry-coordinator/src/deployment.rs +++ b/crates/registry-coordinator/src/deployment.rs @@ -582,10 +582,16 @@ fn layout() -> Result { .map_err(|_| fail("restore the activation layout")) } fn extras(namespace: &str) -> Vec { - ["runs", "jobs", "admission_lock", "completion_waits"] - .into_iter() - .map(|s| format!("{namespace}.{s}")) - .collect() + [ + "runs", + "jobs", + "admission_lock", + "calendar_cursors", + "completion_waits", + ] + .into_iter() + .map(|s| format!("{namespace}.{s}")) + .collect() } async fn check_control_role(client: &impl tokio_postgres::GenericClient, role: &str) -> Result<()> { let row=client.query_one("SELECT has_table_privilege($1,'control','SELECT'),has_table_privilege($1,'control','INSERT,DELETE,TRUNCATE') OR has_column_privilege($1,'control','id','UPDATE') OR has_column_privilege($1,'control','database_id','UPDATE') OR has_column_privilege($1,'control','schema_version','UPDATE') OR has_column_privilege($1,'control','active_flows_digest','UPDATE') OR has_column_privilege($1,'control','active_flows','UPDATE') OR has_column_privilege($1,'control','admission_key_commitment','UPDATE') OR has_column_privilege($1,'control','state_key_commitments','UPDATE'),has_column_privilege($1,'control','restore_hold','UPDATE') AND has_column_privilege($1,'control','admissions_hold','UPDATE') AND has_column_privilege($1,'control','restore_evidence_hash','UPDATE') AND has_column_privilege($1,'control','admission_recovery_hash','UPDATE')",&[&role]).await.map_err(|_|fail("restore the narrow runtime control grants"))?; diff --git a/crates/registry-coordinator/src/event_trigger_tests.rs b/crates/registry-coordinator/src/event_trigger_tests.rs index ab01eb14d7..bdff20d6e1 100644 --- a/crates/registry-coordinator/src/event_trigger_tests.rs +++ b/crates/registry-coordinator/src/event_trigger_tests.rs @@ -685,7 +685,11 @@ workflow: #[cfg(feature = "postgres-test")] mod postgres { use super::*; - use crate::{runtime::RuntimeConfig, store::Store}; + use crate::{ + runtime::RuntimeConfig, + store::{Job, Store}, + }; + use registry_platform_dispatch::postgres::LeasedJob; fn runtime(namespace: &str) -> RuntimeConfig { serde_json::from_value(json!({ @@ -1102,4 +1106,281 @@ mod postgres { .await .unwrap(); } + + fn proposal_submitted(proposal: Uuid, version: u32, revision: u64) -> Value { + let id = Uuid::new_v4().to_string(); + json!({ + "id":id,"type":"proposal.submitted","source":BREG_SOURCE, + "time":"2020-01-01T00:00:00.123Z", + "subject":{"proposalReference":format!("/v1/proposals/{proposal}"),"proposalRevision":revision}, + "dataschema":"https://id.registrystack.org/contracts/breg/proposal-event/v1", + "data":{"actionId":"held-application","actionRevision":format!("sha256:{}", "1".repeat(64)), + "proposalId":proposal,"submissionVersion":version,"revision":revision, + "fromState":"draft","state":"submitted","transition":"submitted", + "effectDigest":format!("sha256:{}", "2".repeat(64)),"authorityKind":"caller", + "systemApplyGrantRevision":null,"turn":"official"}, + "causation":{"root":id,"hop":0} + }) + } + + fn proposal_hold_definition() -> Definition { + let mut data: Value = serde_json::from_str(include_str!( + "../../../products/breg/generated/event-schemas/proposal-lifecycle.schema.json" + )) + .unwrap(); + data.as_object_mut().unwrap().remove("$id"); + data.as_object_mut().unwrap().remove("$schema"); + let mut project = registry_platform_yaml::Reader::new("workflow.yaml") + .scan(project_text().as_bytes()) + .unwrap() + .unwrap() + .to_json_value(); + let event = &mut project["workflow"]["eventTriggers"]["record-saved"]; + event["eventType"] = json!("proposal.submitted"); + event["dataSchema"] = + json!("https://id.registrystack.org/contracts/breg/proposal-event/v1"); + event["event"] = json!({"type":"object","required":["data"],"properties":{"data":data}}); + event["input"]["fields"]["recordId"]["path"] = json!("/data/proposalId"); + definition(&project.to_string()) + } + + async fn release_held_submission( + store: &Store, + client: &mut tokio_postgres::Client, + namespace: &str, + proposal: Uuid, + version: i64, + job: &LeasedJob, + attempt: Uuid, + ) -> std::result::Result { + if !store.before_io(job, false).await.unwrap() { + return Err("run-lease-not-current"); + } + let tx = client.transaction().await.unwrap(); + // Only the host's leased job supplies identity to the proof. The + // workflow input does not participate in admission provenance. + let run = job.key.id(); + let provenance = tx + .query_opt( + &format!( + "SELECT event_binding FROM {namespace}.runs + WHERE run_id=$1 AND start_identity=$2" + ), + &[&run, &job.job.start_identity], + ) + .await + .unwrap() + .and_then(|row| row.get::<_, Option>(0)) + .ok_or("run-not-event-admitted")?; + let row = tx + .query_opt( + &format!( + "SELECT expected_run_id,expected_event_binding,release_attempt,review_receipt + FROM {namespace}.synthetic_held_submissions + WHERE proposal_id=$1 AND submission_version=$2 FOR UPDATE" + ), + &[&proposal, &version], + ) + .await + .unwrap() + .ok_or("hold-absent")?; + if row.get::<_, Uuid>(0) != run || row.get::<_, String>(1) != provenance { + return Err("run-does-not-own-hold"); + } + if let Some(release_attempt) = row.get::<_, Option>(2) { + return if release_attempt == attempt { + Ok(row.get::<_, Option>(3).unwrap()) + } else { + Err("hold-already-released") + }; + } + let receipt = Uuid::new_v4(); + tx.execute( + &format!( + "INSERT INTO {namespace}.synthetic_open_reviews + (receipt_id,proposal_id,submission_version) VALUES ($1,$2,$3)" + ), + &[&receipt, &proposal, &version], + ) + .await + .unwrap(); + let updated = tx + .execute( + &format!( + "UPDATE {namespace}.synthetic_held_submissions SET release_attempt=$3,review_receipt=$4 + WHERE proposal_id=$1 AND submission_version=$2" + ), + &[&proposal, &version, &attempt, &receipt], + ) + .await + .unwrap(); + assert_eq!(updated, 1); + tx.commit().await.unwrap(); + Ok(receipt) + } + + /// This proves live Coordinator signature verification, durable event admission and a + /// PostgreSQL release invariant. The BReg hold, command handoff, Casework request and UX are + /// deliberately simulated; this is not a production BReg release implementation or claim. + #[tokio::test] + async fn held_proposal_release_requires_the_exact_event_run_and_version_and_opens_once() { + let database = std::env::var("COORDINATOR_TEST_DATABASE_URL") + .expect("a disposable real PostgreSQL database is required"); + let namespace = format!("coordinator_{}", Uuid::new_v4().simple()); + let store = Store::connect(&database, &namespace).await.unwrap(); + store.migrate().await.unwrap(); + let runtime = runtime(&namespace); + let definition = proposal_hold_definition(); + let trigger = &definition.workflow.event_triggers["record-saved"]; + let verify = |value: &Value| { + let (body, headers) = signed(value, TARGET, 1, "proposal-delivery"); + verify_delivery( + &source(), + "registry", + trigger, + "record-saved", + &Method::POST, + TARGET, + &headers, + &body, + KEY, + SystemTime::now(), + ) + .unwrap() + }; + let proposal = Uuid::new_v4(); + let current = proposal_submitted(proposal, 2, 2); + let input = trigger.map(&definition, ¤t).unwrap(); + let run = admit(&store, &definition, &runtime, verify(¤t)) + .await + .unwrap(); + let (mut client, connection) = tokio_postgres::connect(&database, tokio_postgres::NoTls) + .await + .unwrap(); + tokio::spawn(async move { + connection.await.unwrap(); + }); + client + .batch_execute(&format!( + "CREATE TABLE {namespace}.synthetic_held_submissions ( + proposal_id uuid NOT NULL, submission_version bigint NOT NULL, + expected_run_id uuid NOT NULL, expected_event_binding text NOT NULL, + release_attempt uuid, review_receipt uuid, + PRIMARY KEY(proposal_id,submission_version) + ); + CREATE TABLE {namespace}.synthetic_open_reviews ( + receipt_id uuid PRIMARY KEY, proposal_id uuid NOT NULL, + submission_version bigint NOT NULL, UNIQUE(proposal_id,submission_version) + );" + )) + .await + .unwrap(); + let event_binding: String = client + .query_one( + &format!("SELECT event_binding FROM {namespace}.runs WHERE run_id=$1"), + &[&run], + ) + .await + .unwrap() + .get::<_, Option>(0) + .unwrap(); + client + .execute( + &format!( + "INSERT INTO {namespace}.synthetic_held_submissions + (proposal_id,submission_version,expected_run_id,expected_event_binding) + VALUES ($1,2,$2,$3)" + ), + &[&proposal, &run, &event_binding], + ) + .await + .unwrap(); + drop(store); + + let reopened = Store::connect(&database, &namespace).await.unwrap(); + assert_eq!( + admit(&reopened, &definition, &runtime, verify(¤t)) + .await + .unwrap(), + run, + "exact redelivery after reopening keeps the original Run" + ); + let older = proposal_submitted(proposal, 1, 1); + assert_eq!( + trigger.map(&definition, &older).unwrap(), + input, + "the signed versions intentionally have identical authored Workflow input" + ); + let older_run = admit(&reopened, &definition, &runtime, verify(&older)) + .await + .unwrap(); + let binding = runtime.binding_digest_for(&definition.workflow).unwrap(); + let manual_run = reopened + .admit( + &definition, + input, + "same-service-connection", + "manual-run", + &binding, + ) + .await + .unwrap(); + let dispatcher = reopened.dispatcher().unwrap(); + let mut jobs = BTreeMap::new(); + for _ in 0..3 { + let job = dispatcher.claim().await.unwrap().leased().unwrap(); + assert!(jobs.insert(job.key.id(), job).is_none()); + } + let owner = &jobs[&run]; + let attempt = Uuid::new_v4(); + let release = + async |client: &mut tokio_postgres::Client, version, job: &LeasedJob, attempt| { + release_held_submission( + &reopened, client, &namespace, proposal, version, job, attempt, + ) + .await + }; + assert_eq!( + release(&mut client, 2, &jobs[&manual_run], attempt).await, + Err("run-not-event-admitted") + ); + assert_eq!( + release(&mut client, 2, &jobs[&older_run], attempt).await, + Err("run-does-not-own-hold") + ); + assert_eq!( + release(&mut client, 1, owner, attempt).await, + Err("hold-absent") + ); + let mut replaced_lease = owner.clone(); + replaced_lease.lease_token = Uuid::new_v4(); + assert_eq!( + release(&mut client, 2, &replaced_lease, attempt).await, + Err("run-lease-not-current") + ); + let receipt = release(&mut client, 2, owner, attempt).await.unwrap(); + assert_eq!( + release(&mut client, 2, owner, attempt).await.unwrap(), + receipt + ); + assert_eq!( + release(&mut client, 2, owner, Uuid::new_v4()).await, + Err("hold-already-released") + ); + assert_eq!( + client + .query_one( + &format!("SELECT count(*) FROM {namespace}.synthetic_open_reviews"), + &[] + ) + .await + .unwrap() + .get::<_, i64>(0), + 1 + ); + client + .batch_execute(&format!("DROP SCHEMA {namespace} CASCADE")) + .await + .unwrap(); + } } diff --git a/crates/registry-coordinator/src/lib.rs b/crates/registry-coordinator/src/lib.rs index b1fbfce7c4..45fe4e3802 100644 --- a/crates/registry-coordinator/src/lib.rs +++ b/crates/registry-coordinator/src/lib.rs @@ -5,6 +5,7 @@ pub mod adapters; pub mod authoring; pub mod breg_action; mod breg_proposal; +pub mod calendar_trigger; pub mod compare; pub mod completion; pub mod contracts; diff --git a/crates/registry-coordinator/src/operations.rs b/crates/registry-coordinator/src/operations.rs index 9e7a4abd4e..40f7fb7e7d 100644 --- a/crates/registry-coordinator/src/operations.rs +++ b/crates/registry-coordinator/src/operations.rs @@ -460,6 +460,10 @@ mod tests { let operation = Operation::parse(registered.id).unwrap(); assert_eq!(operation.product(), registered.product); assert_eq!(operation.recovery(), registered.recovery); + assert_eq!( + operation.permits_failure_route(), + matches!(registered.effect, EffectKind::Read | EffectKind::Evaluation) + ); assert_eq!(operation.supports_read_receipt(), registered.read_receipt); assert_eq!( operation.requires_key(), diff --git a/crates/registry-coordinator/src/problem.rs b/crates/registry-coordinator/src/problem.rs index 5881b6603a..ba48d5ff0e 100644 --- a/crates/registry-coordinator/src/problem.rs +++ b/crates/registry-coordinator/src/problem.rs @@ -564,6 +564,9 @@ mod tests { "coordinator.event.configuration", "coordinator.operation.contract-invalid", "coordinator.operation.proposal-action", + "calendar-invalid", + "calendar-overflow", + "calendar-sweep-timeout", "database-identity-invalid", "database-invalid", "database-tls-required", diff --git a/crates/registry-coordinator/src/protocol.rs b/crates/registry-coordinator/src/protocol.rs index d55513049e..9e12b84027 100644 --- a/crates/registry-coordinator/src/protocol.rs +++ b/crates/registry-coordinator/src/protocol.rs @@ -82,6 +82,14 @@ impl Operation { self.descriptor().effect == crate::operations::EffectKind::Evaluation } + /// An authored failure edge is limited to operations whose successful + /// completion itself changes no product state. Evaluations remain + /// non-repeatable after possible dispatch, but a definite pre-dispatch + /// availability failure may exhaust its retries and take that edge. + pub fn permits_failure_route(self) -> bool { + self.is_read() || self.is_evaluation() + } + /// These calls can complete remotely before a local result is committed. pub fn has_dispatch_risk(self) -> bool { self.is_mutating() || self.is_evaluation() diff --git a/crates/registry-coordinator/src/scenarios.rs b/crates/registry-coordinator/src/scenarios.rs index bf3fedff11..6fe967b0bc 100644 --- a/crates/registry-coordinator/src/scenarios.rs +++ b/crates/registry-coordinator/src/scenarios.rs @@ -66,8 +66,8 @@ struct AuthoredScenario { /// The start input, which must satisfy the workflow input schema. input: ForeignValue, /// The synthetic replies each call step receives, by step, in the order - /// its attempts consume them: 1 to 8 per step. A reply after the first - /// needs a recovery for that step. + /// its attempts consume them, up to the runtime attempt limit. A reply + /// after the first needs a recovery for that step. #[cfg_attr(feature = "schema", schemars(schema_with = "replies_schema"))] replies: BTreeMap>, /// The operator recovery each call step receives after a retryable or @@ -222,8 +222,8 @@ enum AuthoredWait { }, /// No event arrives: the clock moves by the step's `timeoutSeconds`, and /// the run continues at its `onTimeout`, or stops as `timed-out` when the - /// step has none. A deadline that passes first stops the run as - /// `deadline-exceeded`. + /// step has none. An earlier service deadline takes `onDeadline`. The + /// hard run deadline stops the run as `deadline-exceeded`. Timeout {}, /// An authorized caller cancels the run while this wait is durably /// parked. The run continues at `onCancel`, or stops as `cancelled` when @@ -309,6 +309,7 @@ enum ExpectedOutcome { registry_platform_yaml::tagged_union!(ExpectedOutcome); const MAX_SCENARIO_BYTES: u64 = 1_048_576; +const MAX_SCENARIO_REPLIES: usize = crate::operations::MAXIMUM_ATTEMPTS as usize; #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -528,8 +529,8 @@ impl CoordinatorScenarios { for (step, replies) in &case.replies { bound( &format!("{path}/replies/{step}"), - (1..=8).contains(&replies.len()), - "expected from 1 to 8 synthetic replies", + (1..=MAX_SCENARIO_REPLIES).contains(&replies.len()), + &format!("expected from 1 to {MAX_SCENARIO_REPLIES} synthetic replies"), ); } } @@ -654,7 +655,7 @@ fn replies_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema "propertyNames": generator.subschema_for::(), "maxProperties": 64, "additionalProperties": { - "type": "array", "minItems": 1, "maxItems": 8, + "type": "array", "minItems": 1, "maxItems": MAX_SCENARIO_REPLIES, "items": generator.subschema_for::(), }, }) @@ -850,6 +851,12 @@ fn execute(definition: &Definition, scenario: &Scenario) -> Result Result { - now = now + let due = now .max(parse_timestamp(&definition.evaluate_synthetic( &step, &scenario.input, &outputs, )?)?) .min(deadline); - step = next.clone(); + if let (Some(at), Some(target)) = ( + service_deadline.filter(|at| *at <= due), + on_service_deadline, + ) { + now = now.max(at); + step = target.to_owned(); + service_deadline = None; + } else { + now = due; + step = next.clone(); + } } Step::WaitFor { wait_for, @@ -895,6 +912,12 @@ fn execute(definition: &Definition, scenario: &Scenario) -> Result { + let step_timeout = now + chrono::Duration::seconds(i64::from(*timeout_seconds)); + let service_timeout = service_deadline.filter(|at| *at <= step_timeout); + let timeout_at = service_timeout.unwrap_or(step_timeout).max(now); + let timeout_route = on_service_deadline + .filter(|_| service_timeout.is_some()) + .or(on_timeout.as_deref()); // A case without waits is refused at the case itself. let at = if scenario.waits.is_empty() { "" @@ -945,11 +968,12 @@ fn execute(definition: &Definition, scenario: &Scenario) -> Result= timeout_at || now >= deadline { + if now > timeout_at + || (now == timeout_at && service_timeout.is_none()) + || now >= deadline + { break; } contract.check_output(response).map_err(|violation| contract_error("scenario.wait-observation", format!("/waits/{step}/responses/{index}{}", violation.path), &violation, @@ -966,8 +990,13 @@ fn execute(definition: &Definition, scenario: &Scenario) -> Result= timeout_at { now = timeout_at.min(deadline); - match on_timeout { - Some(target) if now < deadline => step = target.clone(), + match timeout_route { + Some(target) if now < deadline => { + step = target.to_owned(); + if service_timeout.is_some() { + service_deadline = None; + } + } _ => { report.state = if now >= deadline { "deadline-exceeded" @@ -986,11 +1015,15 @@ fn execute(definition: &Definition, scenario: &Scenario) -> Result { - now = (now + chrono::Duration::seconds(i64::from(*timeout_seconds))) - .min(deadline); - match on_timeout { - Some(route) => step = route.clone(), - None => { + now = timeout_at.min(deadline); + match timeout_route { + Some(target) if now < deadline => { + step = target.to_owned(); + if service_timeout.is_some() { + service_deadline = None; + } + } + _ => { report.state = if now >= deadline { "deadline-exceeded" } else { @@ -1053,6 +1086,7 @@ fn execute(definition: &Definition, scenario: &Scenario) -> Result { @@ -1183,7 +1217,7 @@ fn execute(definition: &Definition, scenario: &Scenario) -> Result { - completion = Some((next.clone(), value.clone())); + completion = Some((next.clone(), Some(value.clone()))); break; } Reply::DeclaredRefusal { declared_refusal } => { @@ -1206,7 +1240,7 @@ fn execute(definition: &Definition, scenario: &Scenario) -> Result Result { + Reply::Retryable { retryable } => { + if let Some(target) = on_failure.as_ref().filter(|_| { + !prior_unknown + && index + 1 == MAX_SCENARIO_REPLIES + && crate::worker::availability_failure(retryable).is_some() + }) { + completion = Some((target.clone(), None)); + break; + } report.state = if prior_unknown { "uncertain" } else { @@ -1264,7 +1306,9 @@ fn execute(definition: &Definition, scenario: &Scenario) -> Result Result<()> { } let adapters: Arc = Arc::new(HttpAdapters::new(&runtime)?); let worker = Worker::new(store.clone(), adapters.clone()); + let calendar = CalendarRunner::default(); let listener = tokio::net::TcpListener::bind(d.listener.bind.socket_addr()) .await .map_err(|_| { @@ -126,7 +128,7 @@ async fn execute(cli: Cli) -> Result<()> { .await .map(|_| ()) }, - || worker.tick(), + || service_tick(&calendar, &worker, &store, &workflows, &runtime), ); tracing::info!( listener = %d.listener.bind.socket_addr(), @@ -137,6 +139,27 @@ async fn execute(cli: Cli) -> Result<()> { serve_until(server, worker, shutdown, stop).await } +async fn service_tick( + calendar: &CalendarRunner, + worker: &Worker, + store: &crate::store::Store, + workflows: &deployment::Workflows, + runtime: &RuntimeConfig, +) -> Result { + let admitted = match calendar.tick(store, workflows, runtime).await { + Ok(admitted) => admitted, + Err(error) => { + tracing::warn!( + code = %error.code, + suggested_action = error.suggested_action.as_deref(), + "calendar admission sweep was refused; continuing existing Run work" + ); + false + } + }; + Ok(worker.tick().await? || admitted) +} + /// Recovery disables event intake, so unavailable ingress secrets must not /// prevent an operator from inspecting and reconciling retained work. fn check_event_ingress_keys(runtime: &RuntimeConfig, recovery_only: bool) -> Result<()> { diff --git a/crates/registry-coordinator/src/store.rs b/crates/registry-coordinator/src/store.rs index fd12988956..7bb2c7d4a2 100644 --- a/crates/registry-coordinator/src/store.rs +++ b/crates/registry-coordinator/src/store.rs @@ -2,6 +2,7 @@ //! Coordinator-owned durable rows, driven by the shared Dispatch lease engine. use crate::{ + calendar_trigger::CalendarTriggerError, definition::{Definition, DefinitionCache, Step, DEFINITION_REFUSED, HELD_RUN_ADVICE}, metrics::{DispatchDepth, Metrics, RunOutcome}, protected_state::StateKeys, @@ -47,7 +48,7 @@ enum InspectionBinding<'a> { /// The protected state schema version. It is written once, when apply creates /// the namespace, to `control.schema_version` and to the activation migration /// ledger, and every state transaction fences on it. -pub(crate) const SCHEMA_VERSION: i32 = 5; +pub(crate) const SCHEMA_VERSION: i32 = 6; /// Identifies a namespace whose tables this runtime created and owns. const NAMESPACE_MARKER: &str = "registry-coordinator protected-state"; @@ -66,9 +67,13 @@ const POOL_SIZE: usize = 32; /// and an idle session takes to prove it still answers before it is replaced. const POOL_TIMEOUT: Duration = Duration::from_secs(5); +/// Calendar sweeps must yield quickly to ordinary starts and worker progress. +/// The whole sweep has a separate process deadline in `CalendarRunner`. +const CALENDAR_ADMISSION_LOCK_TIMEOUT: &str = "50ms"; + const JOIN: &str = "JOIN {schema}.runs AS run ON run.run_id = state.run_id JOIN {schema}.control AS control ON control.id"; const SELECT: SelectSql = SelectSql { - columns: "run.snapshot, run.input, run.outputs, run.deadline_at, run.binding_digest, state.command, state.uncertain, state.receipt_expired, run.deadline_at > transaction_timestamp(), state.pure, run.start_identity, run.cancel_requested, control.restore_hold, run.owner_hash, run.restore_review_required, run.authored_cancel_requested, run.authored_cancel_pending_step", + columns: "run.snapshot, run.input, run.outputs, run.deadline_at, run.binding_digest, state.command, state.uncertain, state.receipt_expired, run.deadline_at > transaction_timestamp(), state.pure, run.start_identity, run.cancel_requested, control.restore_hold, run.owner_hash, run.restore_review_required, run.authored_cancel_requested, run.authored_cancel_pending_step, run.service_deadline_at", joins: JOIN, predicate: "true", }; @@ -107,6 +112,15 @@ pub struct StoreSecurity { pub audit_profile: AuditProfile, } +/// One calendar occurrence admitted by a cursor transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CalendarAdmission { + pub run_id: Uuid, + pub scheduled_at: DateTime, + /// Due occurrences deliberately skipped before this latest admission. + pub skipped: u64, +} + /// Safe progress metadata plus the workflow author's declared terminal output. /// Source records, prepared commands, raw idempotency keys and credentials are omitted. #[derive(Clone, Debug, Serialize)] @@ -364,6 +378,7 @@ pub struct Job { pub restore_review_required: bool, pub authored_cancel_requested: bool, pub authored_cancel_pending_step: Option, + pub(crate) service_deadline_at: Option>, } pub(crate) struct Payload { @@ -429,9 +444,16 @@ impl FrozenCommand { } pub(crate) enum ObservationPermit { - Observe { timed_out: bool }, - Pause { seconds: u32 }, - TimedOut, + Observe { + timed_out: bool, + service_deadline: bool, + }, + Pause { + seconds: u32, + }, + TimedOut { + service_deadline: bool, + }, Exhausted, } @@ -443,6 +465,11 @@ pub struct Detail { pub outcome: Option, pub failure_code: Option<&'static str>, pub failure_path: Option, + /// A definite availability failure exhausted its retries and took the + /// authored failure edge. The category is persisted on the run while the + /// successor is current, but never becomes a Step output. + pub routed_failure: bool, + pub service_deadline_routed: bool, pub uncertain: bool, /// An observation needs operator attention without implying an unknown command. pub recovery_hold: bool, @@ -468,6 +495,8 @@ impl Detail { outcome: None, failure_code: Some(code), failure_path: None, + routed_failure: false, + service_deadline_routed: false, uncertain, recovery_hold: false, observation_pause_seconds: None, @@ -503,12 +532,16 @@ pub(crate) fn schema_statements(namespace: &str) -> Result> { workflow_id text NOT NULL, workflow_version text NOT NULL, definition_digest text NOT NULL, snapshot jsonb, input jsonb, outputs jsonb, event_binding text, binding_digest text NOT NULL, step text NOT NULL, state text NOT NULL DEFAULT 'running', - outcome text, terminal_output jsonb, failure_code text, failure_path text, admitted_at timestamptz NOT NULL, deadline_at timestamptz NOT NULL, + outcome text, terminal_output jsonb, failure_code text, failure_path text, admitted_at timestamptz NOT NULL, deadline_at timestamptz NOT NULL, service_deadline_at timestamptz, cancel_requested boolean NOT NULL DEFAULT false, authored_cancel_requested boolean NOT NULL DEFAULT false, authored_cancel_pending_step text CHECK(authored_cancel_pending_step IS NULL OR authored_cancel_requested), restore_review_required boolean NOT NULL DEFAULT false, completed_at timestamptz, payload_erased_at timestamptz); CREATE TABLE {0}.admission_lock (id boolean PRIMARY KEY CHECK(id)); - INSERT INTO {0}.admission_lock VALUES(true)", namespace, SCHEMA_VERSION), + INSERT INTO {0}.admission_lock VALUES(true); + CREATE TABLE {0}.calendar_cursors ( + workflow_id text NOT NULL, trigger_id text NOT NULL, workflow_revision text NOT NULL, + cursor_at timestamptz NOT NULL, skipped_count bigint NOT NULL DEFAULT 0 CHECK(skipped_count>=0), + PRIMARY KEY(workflow_id,trigger_id))", namespace, SCHEMA_VERSION), ]; statements.extend(job_table(namespace)?.create_statements()); // The core job shape is widened by exactly one product state: a @@ -569,6 +602,16 @@ fn refused(code: &'static str, message: &'static str) -> CoordinatorError { CoordinatorError::new(code, message) } +fn calendar_refused(error: CalendarTriggerError) -> CoordinatorError { + let code = match error { + CalendarTriggerError::InvalidExpression | CalendarTriggerError::InvalidTimezone => { + "calendar-invalid" + } + CalendarTriggerError::Overflow => "calendar-overflow", + }; + CoordinatorError::new(code, error.to_string()) +} + impl Store { /// Test-only harness with fixed synthetic custody and a discarding audit /// writer. Production callers must supply explicit custody and a durable @@ -1140,6 +1183,52 @@ impl Store { expected_revision: &str, event: Option<(&str, &crate::event_trigger::EventTrigger)>, ) -> Result { + let mut client = self.client().await.map_err(unavailable)?; + let tx = client.transaction().await.map_err(unavailable)?; + self.lock_admission(&tx).await?; + let now: DateTime = tx + .query_one("SELECT transaction_timestamp()", &[]) + .await + .map_err(unavailable)? + .get(0); + let (run, inserted) = self + .admit_bound_locked( + &tx, + definition, + input, + producer, + key, + binding_digest, + runtime, + expected_revision, + event, + now, + ) + .await?; + tx.commit().await.map_err(unavailable)?; + if inserted { + self.metrics.record_run_admitted(); + } + Ok(run) + } + + #[expect( + clippy::too_many_arguments, + reason = "The common admission transaction binds caller, command, runtime, revision, trigger and database time independently" + )] + async fn admit_bound_locked( + &self, + tx: &Transaction<'_>, + definition: &Definition, + input: Value, + producer: &str, + key: &str, + binding_digest: &str, + runtime: Option<&crate::runtime::RuntimeConfig>, + expected_revision: &str, + event: Option<(&str, &crate::event_trigger::EventTrigger)>, + now: DateTime, + ) -> Result<(Uuid, bool)> { let event_binding = event.map(|(binding, _)| binding); if binding_digest.is_empty() || binding_digest.len() > 256 { return Err(refused( @@ -1155,9 +1244,6 @@ impl Store { .commitment("event", &[identity.as_bytes(), binding.as_bytes()]) }); let snapshot = definition.snapshot()?; - let mut client = self.client().await.map_err(unavailable)?; - let tx = client.transaction().await.map_err(unavailable)?; - self.lock_admission(&tx).await?; if let Some(row) = tx .query_opt( &format!( @@ -1178,14 +1264,14 @@ impl Store { "this producer, workflow and idempotency key already names different admitted work", )); } - return Ok(row.get(0)); + return Ok((row.get(0), false)); } if expected_revision != definition.digest { return Err(refused("workflow-revision-mismatch", "the expected Workflow revision is not active; discover the active contract before starting new work")); } let run = Uuid::new_v4(); if let Some(runtime) = runtime { - self.check_live_bindings(&tx, runtime).await?; + self.check_live_bindings(tx, runtime).await?; } let input = if let Some((_, trigger)) = event { trigger.map(definition, &input)? @@ -1193,20 +1279,19 @@ impl Store { input }; definition.validate_input(&input)?; - self.require_unheld(&tx).await?; - let now: DateTime = tx - .query_one("SELECT transaction_timestamp()", &[]) - .await - .map_err(unavailable)? - .get(0); + self.require_unheld(tx).await?; let deadline = definition.deadline_at(now)?; - let due = definition.initial_due(&input, now)?; + let service_deadline = definition.service_deadline_at(now)?; + let due = match (definition.initial_due(&input, now)?, service_deadline) { + (Some(due), Some(service_deadline)) => Some(due.min(service_deadline)), + (due, _) => due, + }; let sealed_snapshot = self.seal(run, "snapshot", "", &snapshot)?; let sealed_input = self.seal(run, "input", "", &input)?; let sealed_outputs = self.seal(run, "outputs", "", &BTreeMap::::new())?; - tx.execute(&format!("INSERT INTO {}.runs (run_id,start_identity,input_digest,workflow_id,workflow_version,definition_digest,snapshot,input,binding_digest,step,admitted_at,deadline_at,outputs,owner_hash,event_binding) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)", self.namespace), &[&run, &identity, &input_digest, &definition.workflow.id, &definition.workflow.version, &definition.digest, &sealed_snapshot, &sealed_input, &binding_digest, &definition.workflow.start, &now, &deadline, &sealed_outputs, &producer,&event_binding]).await.map_err(unavailable)?; + tx.execute(&format!("INSERT INTO {}.runs (run_id,start_identity,input_digest,workflow_id,workflow_version,definition_digest,snapshot,input,binding_digest,step,admitted_at,deadline_at,service_deadline_at,outputs,owner_hash,event_binding) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)", self.namespace), &[&run, &identity, &input_digest, &definition.workflow.id, &definition.workflow.version, &definition.digest, &sealed_snapshot, &sealed_input, &binding_digest, &definition.workflow.start, &now, &deadline, &service_deadline, &sealed_outputs, &producer,&event_binding]).await.map_err(unavailable)?; enqueue( - &tx, + tx, &self.table, &JobKey::new(run, definition.workflow.start.clone()).map_err(unavailable)?, due.map(SystemTime::from), @@ -1226,9 +1311,180 @@ impl Store { ) .await .map_err(unavailable)?; + Ok((run, true)) + } + + /// Evaluate and admit at most one latest due occurrence while holding the + /// ordinary admission fence and one durable calendar cursor. A fresh + /// trigger starts at its first observed database time, never by replaying + /// the institution's unbounded past. + pub async fn admit_calendar_due( + &self, + definition: &Definition, + trigger_id: &str, + runtime: &crate::runtime::RuntimeConfig, + ) -> Result> { + let schedule = definition + .workflow + .calendar_triggers + .get(trigger_id) + .ok_or_else(|| { + refused( + "calendar-invalid", + "calendar trigger is not in the checked Workflow definition", + ) + })? + .schedule() + .map_err(calendar_refused)?; + let binding_digest = runtime.binding_digest_for(&definition.workflow)?; + let producer = self.security.keys.commitment( + "calendar-owner", + &[ + self.security.database_id.as_bytes(), + definition.workflow.id.as_bytes(), + trigger_id.as_bytes(), + ], + ); + let mut client = self.client().await.map_err(unavailable)?; + let tx = client.transaction().await.map_err(unavailable)?; + tx.batch_execute(&format!( + "SET LOCAL lock_timeout = '{CALENDAR_ADMISSION_LOCK_TIMEOUT}'" + )) + .await + .map_err(unavailable)?; + self.lock_admission(&tx).await?; + self.require_unheld(&tx).await?; + let now: DateTime = tx + .query_one("SELECT transaction_timestamp()", &[]) + .await + .map_err(unavailable)? + .get(0); + let inserted = tx + .execute( + &format!( + "INSERT INTO {}.calendar_cursors (workflow_id,trigger_id,workflow_revision,cursor_at) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING", + self.namespace + ), + &[ + &definition.workflow.id, + &trigger_id, + &definition.digest, + &now, + ], + ) + .await + .map_err(unavailable)?; + let row = tx + .query_one( + &format!( + "SELECT workflow_revision,cursor_at FROM {}.calendar_cursors WHERE workflow_id=$1 AND trigger_id=$2 FOR UPDATE", + self.namespace + ), + &[&definition.workflow.id, &trigger_id], + ) + .await + .map_err(unavailable)?; + if inserted == 1 { + tx.commit().await.map_err(unavailable)?; + return Ok(None); + } + let workflow_revision: String = row.get(0); + let cursor: DateTime = row.get(1); + if workflow_revision != definition.digest { + tx.execute( + &format!( + "UPDATE {}.calendar_cursors SET workflow_revision=$3,cursor_at=GREATEST(cursor_at,$4) WHERE workflow_id=$1 AND trigger_id=$2", + self.namespace + ), + &[ + &definition.workflow.id, + &trigger_id, + &definition.digest, + &now, + ], + ) + .await + .map_err(unavailable)?; + tx.commit().await.map_err(unavailable)?; + return Ok(None); + } + let catch_up = schedule + .latest_since(cursor, now) + .map_err(calendar_refused)?; + let Some(occurrence) = catch_up.latest else { + tx.commit().await.map_err(unavailable)?; + return Ok(None); + }; + let skipped = i64::try_from(catch_up.skipped).map_err(|_| { + refused( + "calendar-overflow", + "calendar catch-up exceeds the supported durable count", + ) + })?; + let request = self + .security + .audit + .begin( + AUDIT_SCHEMA, + Uuid::new_v4().to_string(), + serde_json::json!({ + "action":"calendar-admit", + "workflowRef":self.reference("workflow-v1",&definition.workflow.id), + "triggerRef":self.reference("calendar-trigger-v1",trigger_id), + "outcome":"unknown" + }), + serde_json::json!({"action":"calendar-admit","outcome":"unknown"}), + ) + .await + .map_err(|_| refused("audit-unavailable", "durable audit is unavailable"))?; + let key = occurrence.scheduled_at.to_rfc3339(); + let (run, inserted) = self + .admit_bound_locked( + &tx, + definition, + occurrence.input(), + &producer, + &key, + &binding_digest, + Some(runtime), + &definition.digest, + None, + now, + ) + .await?; + tx.execute( + &format!( + "UPDATE {}.calendar_cursors SET workflow_revision=$3,cursor_at=$4,skipped_count=skipped_count+$5 WHERE workflow_id=$1 AND trigger_id=$2", + self.namespace + ), + &[ + &definition.workflow.id, + &trigger_id, + &definition.digest, + &now, + &skipped, + ], + ) + .await + .map_err(unavailable)?; tx.commit().await.map_err(unavailable)?; - self.metrics.record_run_admitted(); - Ok(run) + if inserted { + self.metrics.record_run_admitted(); + } + request + .finish(serde_json::json!({ + "action":"calendar-admit", + "outcome":"accepted", + "runRef":self.reference("run-v1",&run.to_string()), + "skipped":catch_up.skipped + })) + .await + .map_err(|_| refused("audit-response-unavailable", "calendar admission may have committed; inspect the durable cursor and run before retrying"))?; + Ok(Some(CalendarAdmission { + run_id: run, + scheduled_at: occurrence.scheduled_at, + skipped: catch_up.skipped, + })) } async fn require_unheld(&self, tx: &Transaction<'_>) -> Result<()> { @@ -1869,6 +2125,7 @@ impl Store { return Ok("expired"); } let dispatch_risk = matches!(definition.workflow.steps.get(step),Some(Step::Call{call,..}) if call.operation.has_dispatch_risk()); + let service_deadline_routed = detail.service_deadline_routed && !cancelled; let mut outputs = payload.outputs; if let Some(value) = &detail.output { outputs.insert(step.to_owned(), value.clone()); @@ -1895,7 +2152,20 @@ impl Store { .and_then(|text| DateTime::parse_from_rfc3339(text).ok()) }); match mapped { - Some(value) => Some(SystemTime::from(value.with_timezone(&Utc))), + Some(value) => { + let due = value.with_timezone(&Utc); + let due = if definition.workflow.service_deadline.is_some() + && !service_deadline_routed + { + tx.query_one( + &format!("SELECT LEAST($2::timestamptz,service_deadline_at) FROM {}.runs WHERE run_id=$1", self.namespace), + &[&id, &due], + ).await?.get(0) + } else { + due + }; + Some(SystemTime::from(due)) + } None => { failure = Some("mapping-invalid"); None @@ -1941,7 +2211,8 @@ impl Store { } else { "running" }; - let failure_code = failure; + let failure_code = + failure.or_else(|| detail.failure_code.filter(|_| detail.routed_failure)); let terminal = if !cancelled && detail.outcome.is_some() { detail .output @@ -1952,7 +2223,10 @@ impl Store { } else { None }; - tx.execute(&format!("UPDATE {}.runs SET outputs=$2,step=$3,state=$4,outcome=$5,terminal_output=$6,failure_code=$7,failure_path=NULL,completed_at=CASE WHEN $4<>'running' THEN transaction_timestamp() ELSE NULL END WHERE run_id=$1",self.namespace),&[&id,&self.seal(id,"outputs","",&outputs).map_err(|_|DispatchError::Unavailable)?,&next,&state,&if cancelled{None}else{detail.outcome.as_deref()},&terminal,&failure_code]).await?; + let advanced = tx.execute(&format!("UPDATE {}.runs SET outputs=$2,step=$3,state=$4,outcome=$5,terminal_output=$6,failure_code=$7,failure_path=NULL,completed_at=CASE WHEN $4<>'running' THEN transaction_timestamp() ELSE NULL END,service_deadline_at=CASE WHEN $8 THEN NULL ELSE service_deadline_at END WHERE run_id=$1 AND (NOT $8 OR service_deadline_at<=clock_timestamp())",self.namespace),&[&id,&self.seal(id,"outputs","",&outputs).map_err(|_|DispatchError::Unavailable)?,&next,&state,&if cancelled{None}else{detail.outcome.as_deref()},&terminal,&failure_code,&service_deadline_routed]).await?; + if advanced != 1 { + return Err(DispatchError::Unavailable); + } if !cancelled && failure.is_none() { if let Some(next) = &detail.next { enqueue( @@ -1967,7 +2241,7 @@ impl Store { i32::try_from(*timeout_seconds).map_err(|_| DispatchError::Unavailable)?; tx.execute( &format!( - "INSERT INTO {0}.completion_waits(run_id,step,wait_key,timeout_at,follows_proposal) SELECT $1,$2,$3,LEAST(r.deadline_at,transaction_timestamp()+$4::integer*interval '1 second'),$5 FROM {0}.runs r WHERE r.run_id=$1", + "INSERT INTO {0}.completion_waits(run_id,step,wait_key,timeout_at,follows_proposal) SELECT $1,$2,$3,LEAST(r.deadline_at,r.service_deadline_at,transaction_timestamp()+$4::integer*interval '1 second'),$5 FROM {0}.runs r WHERE r.run_id=$1", self.namespace ), &[&id, next, key, &timeout_seconds, follows], @@ -2153,17 +2427,19 @@ impl Store { if valid.is_none() { return Ok(None); } - let Some(row)=tx.query_opt(&format!("SELECT timeout_at,clock_timestamp(),not_before,observations,started_at,GREATEST(interval_seconds,$3) FROM {}.completion_waits WHERE run_id=$1 AND step=$2 FOR UPDATE",self.namespace),&[&fence.id,&fence.part,&interval]).await.map_err(unavailable)? else{return Ok(None)}; + let Some(row)=tx.query_opt(&format!("SELECT w.timeout_at,clock_timestamp(),w.not_before,w.observations,w.started_at,GREATEST(w.interval_seconds,$3),COALESCE(w.timeout_at=r.service_deadline_at,false) FROM {0}.completion_waits w JOIN {0}.runs r ON r.run_id=w.run_id WHERE w.run_id=$1 AND w.step=$2 FOR UPDATE OF w",self.namespace),&[&fence.id,&fence.part,&interval]).await.map_err(unavailable)? else{return Ok(None)}; let timeout: DateTime = row.get(0); let now: DateTime = row.get(1); let not_before: DateTime = row.get(2); let observations: i32 = row.get(3); let started: DateTime = row.get(4); let interval: i32 = row.get(5); + let timed_out = now >= timeout; + let service_deadline = timed_out && row.get::<_, bool>(6); let ceiling = observation_limit(started, timeout, interval as u32); if now < not_before { - return Ok(Some(if now >= timeout { - ObservationPermit::TimedOut + return Ok(Some(if timed_out { + ObservationPermit::TimedOut { service_deadline } } else { ObservationPermit::Pause { seconds: ((not_before.min(timeout) - now).num_milliseconds().max(1) as u64) @@ -2172,8 +2448,8 @@ impl Store { })); } if observations as u32 >= ceiling { - return Ok(Some(if now >= timeout { - ObservationPermit::TimedOut + return Ok(Some(if timed_out { + ObservationPermit::TimedOut { service_deadline } } else { ObservationPermit::Exhausted })); @@ -2181,7 +2457,8 @@ impl Store { tx.execute(&format!("UPDATE {}.completion_waits SET read_at=$3,not_before=$3::timestamptz+$4::integer*interval '1 second',observations=observations+1,interval_seconds=$4 WHERE run_id=$1 AND step=$2",self.namespace),&[&fence.id,&fence.part,&now,&interval]).await.map_err(unavailable)?; tx.commit().await.map_err(unavailable)?; Ok(Some(ObservationPermit::Observe { - timed_out: now >= timeout, + timed_out, + service_deadline, })) } @@ -2289,6 +2566,7 @@ fn decode(row: &Row, first: usize) -> std::result::Result { restore_review_required: row.try_get(first + 14)?, authored_cancel_requested: row.try_get(first + 15)?, authored_cancel_pending_step: row.try_get(first + 16)?, + service_deadline_at: row.try_get(first + 17)?, }) } /// The least time between two reads of one review wait, whatever wakes it. @@ -2540,7 +2818,7 @@ impl DispatchStore for Store { // This runs after Dispatch's reset under the same job/control locks; // a refused schedule write cannot leave an immediately runnable job. let changed = tx.execute( - &format!("UPDATE {}.jobs SET next_attempt_at=GREATEST($3::timestamptz,transaction_timestamp()) WHERE run_id=$1 AND step=$2 AND generation=$4 AND state='pending'", self.namespace), + &format!("UPDATE {0}.jobs j SET next_attempt_at=GREATEST(LEAST($3::timestamptz,r.service_deadline_at),transaction_timestamp()) FROM {0}.runs r WHERE r.run_id=j.run_id AND j.run_id=$1 AND j.step=$2 AND j.generation=$4 AND j.state='pending'", self.namespace), &[&audit.key.id(), &audit.key.part(), &due, &audit.generation], ).await?; if changed != 1 { @@ -2931,15 +3209,15 @@ mod tests { /// every statement that creates the schema, including the shared dispatch /// job table, its product widening, and the activation ledger. #[test] - fn schema_version_5_is_pinned() { + fn schema_version_6_is_pinned() { let mut ddl = schema_statements("coordinator_pin").expect("a valid namespace"); ddl.push(crate::deployment::activation_ledger_statements()); let digest = hex::encode(Sha256::digest(ddl.join("\n"))); assert_eq!( (SCHEMA_VERSION, digest.as_str()), ( - 5, - "acd91ebb3a5a9ee98b0b83a5900b5dedd2648d150fbd0b22049acc1d8cb38817" + 6, + "8d33ddc04df7fd5c3cac84e1e15d5a288892522c04ce22f989c306ff6e8f95d2" ), "the protected state schema DDL changed at this SCHEMA_VERSION; {MIGRATION_PIN_RULE}" ); @@ -2964,6 +3242,7 @@ mod tests { restore_review_required: false, authored_cancel_requested: false, authored_cancel_pending_step: None, + service_deadline_at: None, } } diff --git a/crates/registry-coordinator/src/store/recovery.rs b/crates/registry-coordinator/src/store/recovery.rs index 3bb3c12f29..eb7010f1ac 100644 --- a/crates/registry-coordinator/src/store/recovery.rs +++ b/crates/registry-coordinator/src/store/recovery.rs @@ -538,7 +538,7 @@ impl Store { let input_row=tx.query_one(&format!("SELECT input FROM {}.runs WHERE run_id=$1",self.namespace),&[&run]).await.map_err(unavailable)?; let input:Value=self.open_value(run,"input","",&input_row.get::<_,Value>(0))?; let payload=Payload{snapshot,input,outputs,command:Some(command)}; - let detail=Detail{next:Some(next.clone()),output:Some(value),outcome:None,failure_code:None,failure_path:None,uncertain:false,recovery_hold:false,observation_pause_seconds:None,receipt_expired:false,completed_without_effect:false,recheck_seconds:None,follow:None,hold_detail:None}; + let detail=Detail{next:Some(next.clone()),output:Some(value),outcome:None,failure_code:None,failure_path:None,routed_failure:false,service_deadline_routed:false,uncertain:false,recovery_hold:false,observation_pause_seconds:None,receipt_expired:false,completed_without_effect:false,recheck_seconds:None,follow:None,hold_detail:None}; let written=self.apply_success(&tx,run,&step,payload,&detail,current.get(4)).await.map_err(unavailable)?; let mapping_failed=written=="failed"; tx.execute(&format!("UPDATE {}.jobs SET state='delivered',attempt=GREATEST(attempt,1),uncertain=false,next_attempt_at=NULL,attempt_started_at=NULL,lease_expires_at=NULL,lease_token=NULL,delivered_at=transaction_timestamp(),dead_lettered_at=NULL,expired_at=NULL,failure_code=NULL,failure_path=NULL,hold_detail=NULL WHERE run_id=$1 AND step=$2",self.namespace),&[&run,&step]).await.map_err(unavailable)?; @@ -1086,7 +1086,12 @@ impl Store { Some(Step::Choose { cases, .. }) => { pending.extend(cases.values().map(String::as_str)) } - Some(Step::WaitUntil { next, .. }) => pending.push(next.as_str()), + Some(Step::WaitUntil { next, .. }) => { + pending.push(next.as_str()); + if let Some(deadline) = &definition.workflow.service_deadline { + pending.push(deadline.next.as_str()); + } + } Some(Step::Finish { .. }) => {} } } diff --git a/crates/registry-coordinator/src/worker.rs b/crates/registry-coordinator/src/worker.rs index 8c019c2bf4..aef059964c 100644 --- a/crates/registry-coordinator/src/worker.rs +++ b/crates/registry-coordinator/src/worker.rs @@ -92,6 +92,8 @@ fn advance(next: String, output: Option) -> Detail { outcome: None, failure_code: None, failure_path: None, + routed_failure: false, + service_deadline_routed: false, uncertain: false, recovery_hold: false, observation_pause_seconds: None, @@ -103,14 +105,41 @@ fn advance(next: String, output: Option) -> Detail { } } +fn deadline_route(target: &str) -> Detail { + Detail { + service_deadline_routed: true, + ..advance(target.to_owned(), None) + } +} + fn call_result( result: CallOutcome, next: &str, on_declared_refusal: Option<&str>, uncertain: bool, +) -> Sent { + call_result_with_failure(result, next, None, on_declared_refusal, uncertain, false) +} + +fn call_result_with_failure( + result: CallOutcome, + next: &str, + on_failure: Option<&str>, + on_declared_refusal: Option<&str>, + uncertain: bool, + exhausted: bool, ) -> Sent { match result { CallOutcome::Success(value) => accepted(advance(next.to_owned(), Some(value))), + CallOutcome::Retryable { code, .. } if !uncertain && exhausted && on_failure.is_some() => { + let Some(category) = availability_failure(&code) else { + return failed(safe_code(&code, "remote-retryable"), false); + }; + let mut detail = advance(on_failure.expect("checked above").to_owned(), None); + detail.failure_code = Some(category); + detail.routed_failure = true; + accepted(detail) + } CallOutcome::Retryable { code, retry_after } if !uncertain => Sent { outcome: SendOutcome::Transient { retry_after }, detail: Detail::failure(safe_code(&code, "remote-retryable"), false), @@ -155,6 +184,22 @@ fn call_result( } } +/// Definite, effect-free availability categories that an authored failure +/// edge may handle after the ordinary retry policy is exhausted. Credential, +/// configuration, authorization, preparation, binding and protocol failures +/// are deliberately absent. +pub(crate) fn availability_failure(code: &str) -> Option<&'static str> { + match code { + "transport-unavailable" => Some("transport-unavailable"), + "rate-limited" => Some("rate-limited"), + "attempt-timeout" => Some("attempt-timeout"), + "external-unavailable" => Some("external-unavailable"), + "external-rate-limited" => Some("external-rate-limited"), + "decision-unavailable" => Some("decision-unavailable"), + _ => None, + } +} + /// A busy answer that may follow an effect keeps its automatic retry only /// when Dispatch is sure to schedule it. Dispatch expires a job whose retry /// would land at or after its expiry, and an expired job cannot be replayed, @@ -230,6 +275,7 @@ fn wait_result( read: Option, next: &str, on_timeout: Option<&str>, + on_service_deadline: Option<&str>, timed_out: bool, recheck_seconds: u32, ) -> Sent { @@ -278,15 +324,17 @@ fn wait_result( Some(*read), next, on_timeout, + on_service_deadline, timed_out, recheck_seconds, ); result.detail.follow = Some(proposal); result } - _ if timed_out => match on_timeout { - Some(step) => accepted(advance(step.to_owned(), None)), - None => failed("wait-timed-out", false), + _ if timed_out => match (on_service_deadline, on_timeout) { + (Some(target), _) => accepted(deadline_route(target)), + (None, Some(step)) => accepted(advance(step.to_owned(), None)), + (None, None) => failed("wait-timed-out", false), }, Some(CompletionObservation::Pending { retry_after }) => pending(None, retry_after), Some(CompletionObservation::Retryable { code, retry_after }) => pending( @@ -335,10 +383,24 @@ impl Worker { return Ok(failed("step-invalid", job.job.uncertain)); }; let evaluate = || definition.evaluate(job.key.part(), &payload.input, &payload.outputs); + let deadline_target = definition + .workflow + .service_deadline + .as_ref() + .map(|deadline| deadline.next.as_str()); match step { // Acceptance checkpoints the timer wake. Store checks the current // deadline atomically before advancing the workflow at commit. - Step::WaitUntil { next, .. } => Ok(accepted(advance(next.clone(), None))), + Step::WaitUntil { next, .. } => { + let service_deadline_due = job + .job + .service_deadline_at + .is_some_and(|at| SystemTime::from(at) <= job.attempt_started_at); + let detail = deadline_target + .filter(|_| service_deadline_due) + .map_or_else(|| advance(next.clone(), None), deadline_route); + Ok(accepted(detail)) + } Step::WaitFor { wait_for, next, @@ -390,8 +452,11 @@ impl Worker { .begin_observation(job, read_floor) .await .map_err(|_| DispatchError::Unavailable)?; - let timed_out = match permit { - Some(crate::store::ObservationPermit::Observe { timed_out }) => timed_out, + let (timed_out, service_deadline) = match permit { + Some(crate::store::ObservationPermit::Observe { + timed_out, + service_deadline, + }) => (timed_out, service_deadline), Some(crate::store::ObservationPermit::Pause { seconds }) => { return Ok(accepted(Detail { recheck_seconds: Some(seconds), @@ -399,12 +464,13 @@ impl Worker { ..Detail::failure("observation-pending", false) })); } - Some(crate::store::ObservationPermit::TimedOut) => { + Some(crate::store::ObservationPermit::TimedOut { service_deadline }) => { return Ok(wait_result( completion_contract, None, next, on_timeout.as_deref(), + deadline_target.filter(|_| service_deadline), true, recheck_seconds, )); @@ -414,12 +480,14 @@ impl Worker { } None => return Ok(held("protected-state-invalid")), }; + let on_service_deadline = deadline_target.filter(|_| service_deadline); let Some(budget) = job.remaining_budget(std::time::SystemTime::now()) else { return Ok(wait_result( completion_contract, None, next, on_timeout.as_deref(), + on_service_deadline, timed_out, recheck_seconds, )); @@ -463,6 +531,7 @@ impl Worker { read, next, on_timeout.as_deref(), + on_service_deadline, timed_out, recheck_seconds, )) @@ -494,6 +563,8 @@ impl Worker { outcome: Some(finish.clone()), failure_code: None, failure_path: None, + routed_failure: false, + service_deadline_routed: false, uncertain: false, recovery_hold: false, observation_pause_seconds: None, @@ -507,6 +578,7 @@ impl Worker { Step::Call { call, next, + on_failure, on_declared_refusal, .. } => { @@ -625,13 +697,19 @@ impl Worker { request.operation.has_dispatch_risk() || job.job.uncertain, )); }; - // Dispatch gives up after the policy's last attempt, so a - // receiver's busy answer on that attempt is the workflow's to see. let final_attempt = job.attempt >= job.policy.maximum_attempts; + // Preserve the retryable category on the final external GET + // when the workflow declares an availability edge. + let adapter_final_attempt = final_attempt + && !(request.operation == crate::protocol::Operation::ExternalGet + && on_failure.is_some()); let result = tokio::time::timeout( budget, - self.adapters - .call_attempt(request, command.preparation(), final_attempt), + self.adapters.call_attempt( + request, + command.preparation(), + adapter_final_attempt, + ), ) .await; let result = match result { @@ -675,11 +753,13 @@ impl Worker { request.operation.has_dispatch_risk() || job.job.uncertain, )); } - Ok(call_result( + Ok(call_result_with_failure( retry_or_hold(result, job), next, + on_failure.as_deref(), on_declared_refusal.as_deref(), job.job.uncertain, + final_attempt, )) } } @@ -722,7 +802,9 @@ mod leased_wait_deadline_tests; #[cfg(test)] mod tests { - use super::{attempt_class, call_result, retry_fits, safe_code, wait_result}; + use super::{ + attempt_class, call_result, call_result_with_failure, retry_fits, safe_code, wait_result, + }; use crate::{ contracts::CompletionContract, metrics::StepAttempt, @@ -766,7 +848,34 @@ mod tests { on_timeout: Option<&str>, timed_out: bool, ) -> super::Sent { - wait_result(review(), read, "decide", on_timeout, timed_out, 900) + wait_result(review(), read, "decide", on_timeout, None, timed_out, 900) + } + + #[test] + fn settled_result_at_the_service_deadline_wins() { + let settled = wait_result( + review(), + Some(CompletionObservation::Settled(result("approved"))), + "decide", + Some("retry"), + Some("escalate"), + true, + 900, + ); + assert_eq!(settled.detail.next.as_deref(), Some("decide")); + assert!(!settled.detail.service_deadline_routed); + let pending = wait_result( + review(), + Some(CompletionObservation::Pending { retry_after: None }), + "decide", + Some("retry"), + Some("escalate"), + true, + 900, + ); + assert_eq!(pending.detail.next.as_deref(), Some("escalate")); + assert!(pending.detail.service_deadline_routed); + assert!(pending.detail.output.is_none()); } fn permanent(sent: &super::Sent) -> Option<&'static str> { @@ -943,7 +1052,7 @@ mod tests { assert!(matches!(sent.outcome, SendOutcome::Accepted { .. })); assert_eq!(sent.detail.recheck_seconds, Some(300)); assert_eq!(sent.detail.failure_code, Some("attempt-timeout")); - let sent = wait_result(review(), None, "decide", None, false, 120); + let sent = wait_result(review(), None, "decide", None, None, false, 120); assert_eq!( sent.detail.recheck_seconds, Some(300), @@ -982,6 +1091,71 @@ mod tests { ); } + #[test] + fn only_exhausted_explicit_availability_can_take_a_failure_edge() { + let retryable = |code: &str| CallOutcome::Retryable { + code: code.into(), + retry_after: None, + }; + for category in [ + "transport-unavailable", + "rate-limited", + "attempt-timeout", + "external-unavailable", + "external-rate-limited", + "decision-unavailable", + ] { + let routed = call_result_with_failure( + retryable(category), + "success", + Some("manual"), + None, + false, + true, + ); + assert!(matches!(routed.outcome, SendOutcome::Accepted { .. })); + assert_eq!(routed.detail.next.as_deref(), Some("manual")); + assert_eq!(routed.detail.failure_code, Some(category)); + assert!(routed.detail.routed_failure); + assert_eq!(routed.detail.output, None); + } + + for (code, uncertain, exhausted) in [ + ("product-forbidden", false, true), + ("credential-configuration", false, true), + ("credential-unavailable", false, true), + ("operation-unbound", false, true), + ("transport-unavailable", true, true), + ("transport-unavailable", false, false), + ] { + let stopped = call_result_with_failure( + retryable(code), + "success", + Some("manual"), + None, + uncertain, + exhausted, + ); + assert_eq!(stopped.detail.next, None, "{code}"); + assert!(!stopped.detail.routed_failure, "{code}"); + assert_eq!(stopped.detail.output, None, "{code}"); + } + + let authorization = call_result_with_failure( + CallOutcome::Refused { + code: "product-forbidden".into(), + }, + "success", + Some("manual"), + None, + false, + true, + ); + assert_eq!(authorization.detail.next, None); + assert_eq!(authorization.detail.failure_code, Some("product-forbidden")); + assert!(!authorization.detail.routed_failure); + } + #[test] fn a_busy_answer_that_may_follow_an_effect_retries_with_the_effect_unknown() { let unavailable = |code: &str, retry_after| CallOutcome::UncertainRetryable { diff --git a/crates/registry-coordinator/tests/authored_project.rs b/crates/registry-coordinator/tests/authored_project.rs index 517a702c08..1b6f295ab7 100644 --- a/crates/registry-coordinator/tests/authored_project.rs +++ b/crates/registry-coordinator/tests/authored_project.rs @@ -463,6 +463,137 @@ fn operations_without_a_declared_refusal_contract_reject_the_edge() { ); } +#[test] +fn reads_and_evaluations_accept_on_failure_but_mutations_refuse_it() { + let root = tempfile::tempdir().unwrap(); + let mut read = declared_refusal_project(); + read["operations"]["invoke-breg-action"] = + json!({"uses":"breg.read-record/v1","connection":"registry"}); + read["workflow"]["steps"]["act"] = json!({ + "type":"call", + "operation":"invoke-breg-action", + "input":{"type":"fields","fields":{ + "collection":{"type":"literal","value":"people"}, + "recordId":{"type":"literal","value":"synthetic-record"} + }}, + "next":"accepted", + "onFailure":"refused" + }); + read["workflow"]["steps"]["refused"] = json!({"type":"finish","outcome":"refused"}); + read["workflow"]["outcomes"]["refused"] = json!({"type":"null"}); + write_project(root.path(), &read, ""); + let definition = Definition::load(root.path()).expect("a read may route exhausted failure"); + let snapshot: Value = serde_json::from_str(&definition.snapshot().unwrap()).unwrap(); + assert_eq!(snapshot["workflow"]["steps"]["act"]["onFailure"], "refused"); + let scenarios = root.path().join("scenarios.yaml"); + fs::write(&scenarios, json!({ + "apiVersion": registry_coordinator::scenarios::API_VERSION, + "kind": registry_coordinator::scenarios::KIND, + "cases": [{ + "id":"availability-exhausted", + "admittedAt":"2026-11-10T00:00:00Z", + "input":{}, + "replies":{"act":vec![json!({"type":"retryable","code":"transport-unavailable"});10]}, + "recovery":{"act":"retry-same"}, + "expect":{"path":["act","refused"],"state":"completed"} + }] + }).to_string()).unwrap(); + let cases = registry_coordinator::scenarios::load(&scenarios).unwrap(); + let result = registry_coordinator::scenarios::run(&definition, &cases.cases[0]).unwrap(); + assert_eq!(result.path, ["act", "refused"]); + assert_eq!(result.outcome.as_deref(), Some("refused")); + + let evaluation = json!({ + "apiVersion":"id.registrystack.org/formats/coordinator/project/v1alpha1", + "kind":"CoordinatorProject", + "project":{"id":"evaluation-failure-route","version":"1"}, + "functions":{"command":{ + "runtime":"rhai","source":"functions.rhai","entrypoint":"command", + "inputs":{"arg0":{"type":"object"}}, + "output":{"operationInput":"evaluate"} + }}, + "operations":{"evaluate":{"uses":"evaluation.evaluate/v1","connection":"model"}}, + "workflow":{ + "input":{"type":"object"},"deadlineSeconds":60,"start":"act", + "steps":{ + "act":{"type":"call","operation":"evaluate", + "input":{"type":"function","function":"command","inputs":{"arg0":{"type":"input"}}}, + "next":"accepted","onFailure":"refused"}, + "accepted":{"type":"finish","outcome":"accepted"}, + "refused":{"type":"finish","outcome":"refused"} + }, + "outcomes":{"accepted":{"type":"null"},"refused":{"type":"null"}} + } + }); + fs::write( + root.path().join("workflow.yaml"), + serde_json::to_string(&evaluation).unwrap(), + ) + .unwrap(); + fs::write( + root.path().join("functions.rhai"), + "fn command(__named) { #{ state: __named.arg0, questions: #{ route: #{ type: \"predicate\", instructions: \"Check the application\" } } } }", + ) + .unwrap(); + Definition::load(root.path()).expect("an evaluation may route exhausted failure"); + + let mut mutation = declared_refusal_project(); + mutation["workflow"]["steps"]["act"]["onFailure"] = json!("refused"); + write_project( + root.path(), + &mutation, + "fn command(__named) { let input = __named.arg0; input }", + ); + let error = Definition::load(root.path()) + .err() + .expect("a mutation must refuse onFailure"); + assert_eq!(error.code, "coordinator.definition.operation"); + assert_eq!( + error.field.as_deref(), + Some("/workflow/steps/act/onFailure") + ); +} + +#[test] +fn a_failure_route_has_no_call_output_and_names_a_declared_step() { + let root = tempfile::tempdir().unwrap(); + let source = "fn command(__named) { let input = __named.arg0; input } fn identity(__named) { let value = __named.arg0; value }"; + let mut project = declared_refusal_project(); + project["operations"]["invoke-breg-action"] = + json!({"uses":"breg.read-record/v1","connection":"registry"}); + project["workflow"]["steps"]["act"] = json!({ + "type":"call", + "operation":"invoke-breg-action", + "input":{"type":"fields","fields":{ + "collection":{"type":"literal","value":"people"}, + "recordId":{"type":"literal","value":"synthetic-record"} + }}, + "next":"accepted", + "onFailure":"refused" + }); + project["workflow"]["steps"]["refused"] = json!({ + "type":"finish", + "outcome":"refused", + "output":{"type":"function","function":"identity","inputs":{"arg0":{"type":"step","step":"act"}}} + }); + project["workflow"]["outcomes"]["refused"] = json!({}); + write_project(root.path(), &project, source); + assert_eq!( + Definition::load(root.path()).err().unwrap().code, + "coordinator.mapping.dominance" + ); + + project["workflow"]["steps"]["refused"] = json!({"type":"finish","outcome":"refused"}); + project["workflow"]["steps"]["act"]["onFailure"] = json!("missing"); + write_project(root.path(), &project, source); + let error = Definition::load(root.path()).err().unwrap(); + assert_eq!(error.code, "coordinator.definition.target"); + assert_eq!( + error.field.as_deref(), + Some("/workflow/steps/act/onFailure") + ); +} + #[test] fn a_snapshot_cannot_carry_the_refusal_edge_on_an_operation_without_it() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/registry-coordinator/tests/calendar_trigger_authoring.rs b/crates/registry-coordinator/tests/calendar_trigger_authoring.rs new file mode 100644 index 0000000000..c97b443f17 --- /dev/null +++ b/crates/registry-coordinator/tests/calendar_trigger_authoring.rs @@ -0,0 +1,465 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_coordinator::definition::Definition; +use serde_json::{json, Value}; + +fn project() -> Value { + let input = json!({ + "type":"object", + "additionalProperties":false, + "required":["scheduledAt"], + "properties":{"scheduledAt":{"type":"string","format":"date-time"}} + }); + json!({ + "apiVersion":"id.registrystack.org/formats/coordinator/project/v1alpha1", + "kind":"CoordinatorProject", + "project":{"id":"monthly-report","version":"v1"}, + "workflow":{ + "calendarTriggers":{ + "month-end":{ + "cron":"5 8 L * *", + "timezone":"Asia/Bangkok", + "label":"Month end", + "description":"Start the monthly report in local institutional time." + } + }, + "input":input, + "deadlineSeconds":3600, + "start":"done", + "steps":{ + "done":{"type":"finish","outcome":"reported","output":{"type":"input"}} + }, + "outcomes":{"reported":input} + } + }) +} + +fn write(root: &std::path::Path, value: &Value) { + std::fs::write( + root.join("workflow.yaml"), + serde_norway::to_string(value).unwrap(), + ) + .unwrap(); +} + +#[test] +fn a_calendar_trigger_is_checked_explained_and_restored() { + let root = tempfile::tempdir().unwrap(); + write(root.path(), &project()); + let definition = Definition::load(root.path()).expect("the monthly trigger is valid"); + let explained = definition.explain().unwrap(); + assert_eq!( + explained["calendarTriggers"]["month-end"]["cron"], + "5 8 L * *" + ); + assert_eq!( + explained["calendarTriggers"]["month-end"]["timezone"], + "Asia/Bangkok" + ); + assert_eq!( + explained["presentation"]["calendarTriggers"]["month-end"]["label"], + "Month end" + ); + + let restored = Definition::from_snapshot(&definition.snapshot().unwrap()).unwrap(); + assert_eq!(restored.digest, definition.digest); + assert_eq!( + restored.workflow.calendar_triggers["month-end"].cron, + "5 8 L * *" + ); +} + +#[test] +fn unsupported_calendar_expressions_and_timezones_are_refused_at_authoring() { + let root = tempfile::tempdir().unwrap(); + for (cron, timezone) in [ + ("0 9 * * *", "UTC"), + ("0 9 2 * *", "UTC"), + ("0 9 1 * *", "Not/A_Zone"), + ] { + let mut value = project(); + value["workflow"]["calendarTriggers"]["month-end"]["cron"] = json!(cron); + value["workflow"]["calendarTriggers"]["month-end"]["timezone"] = json!(timezone); + write(root.path(), &value); + let error = Definition::load(root.path()) + .err() + .expect("unsupported calendar trigger"); + assert_eq!(error.code, "coordinator.definition.calendar-trigger"); + assert!( + error + .field + .as_deref() + .is_some_and(|field| field.contains("calendarTriggers/month-end")), + "{error:?}" + ); + } + + let mut two = project(); + two["workflow"]["calendarTriggers"]["another"] = json!({"cron":"0 9 1 * *","timezone":"UTC"}); + write(root.path(), &two); + assert_eq!( + Definition::load(root.path()).err().unwrap().code, + "coordinator.definition.calendar-trigger" + ); + + let mut wrong_input = project(); + wrong_input["workflow"]["input"] = json!({ + "type":"object", + "additionalProperties":false, + "required":["other"], + "properties":{"other":{"type":"string"}} + }); + write(root.path(), &wrong_input); + assert_eq!( + Definition::load(root.path()).err().unwrap().code, + "coordinator.definition.calendar-trigger-input" + ); +} + +#[cfg(feature = "postgres-test")] +#[tokio::test] +async fn calendar_admission_is_latest_only_atomic_and_fenced() { + use async_trait::async_trait; + use registry_coordinator::{ + calendar_trigger::CalendarSchedule, + protocol::{AdapterSet, CallOutcome, CallRequest}, + runtime::RuntimeConfig, + store::Store, + worker::Worker, + }; + use std::sync::Arc; + use uuid::Uuid; + + struct NoCalls(String); + #[async_trait] + impl AdapterSet for NoCalls { + fn binding_digest(&self) -> &str { + &self.0 + } + async fn call(&self, _: &CallRequest) -> CallOutcome { + panic!("the calendar fixture finishes without a product call") + } + } + + async fn client(url: &str) -> tokio_postgres::Client { + let (client, connection) = tokio_postgres::connect(url, tokio_postgres::NoTls) + .await + .unwrap(); + tokio::spawn(async move { connection.await.unwrap() }); + client + } + + let url = std::env::var("COORDINATOR_TEST_DATABASE_URL") + .expect("use a disposable PostgreSQL 17+ database"); + let namespace = format!("coordinator_{}", Uuid::new_v4().simple()); + let base = Store::connect(&url, &namespace).await.unwrap(); + base.migrate().await.unwrap(); + let sql = client(&url).await; + sql.execute( + &format!( + "UPDATE {}.control SET active_flows_digest='calendar-active' WHERE id", + namespace + ), + &[], + ) + .await + .unwrap(); + let first_store = Arc::new(base.with_workflows_digest("calendar-active".into())); + let second_store = Arc::new( + Store::connect(&url, &namespace) + .await + .unwrap() + .with_workflows_digest("calendar-active".into()), + ); + let root = tempfile::tempdir().unwrap(); + let runtime: RuntimeConfig = serde_json::from_value(json!({ + "secretProviders":{ + "environment":{}, + "file":{"root":root.path().canonicalize().unwrap()} + }, + "database":{ + "runtimeUrlRef":"secret:env/COORDINATOR_TEST_DATABASE_URL", + "migrationUrlRef":"secret:env/COORDINATOR_TEST_DATABASE_URL" + }, + "namespace":namespace, + "connections":{ + "source":{ + "type":"breg", + "baseUrl":"http://127.0.0.1:9", + "profile":"synthetic-reader", + "authorization":{ + "tokenEndpoint":"http://127.0.0.1:9/token", + "clientId":"synthetic-client", + "signingKeyRef":"secret:file/client-key", + "resource":"urn:example:synthetic-registry", + "scopes":["records:read"] + } + } + } + })) + .unwrap(); + runtime.validate().unwrap(); + write(root.path(), &project()); + let definition = Arc::new(Definition::load(root.path()).unwrap()); + assert_eq!( + first_store + .admit_calendar_due(&definition, "not-reviewed", &runtime) + .await + .unwrap_err() + .code, + "calendar-invalid", + "Store derives the schedule only from the checked trigger" + ); + assert!(first_store + .admit_calendar_due(&definition, "month-end", &runtime) + .await + .unwrap() + .is_none()); + sql.execute( + &format!( + "UPDATE {}.calendar_cursors SET cursor_at=transaction_timestamp()+interval '1 day' WHERE workflow_id=$1 AND trigger_id=$2", + namespace + ), + &[&definition.workflow.id, &"month-end"], + ) + .await + .unwrap(); + let future = sql + .query_one( + &format!( + "SELECT cursor_at,xmin::text FROM {}.calendar_cursors WHERE workflow_id=$1 AND trigger_id=$2", + namespace + ), + &[&definition.workflow.id, &"month-end"], + ) + .await + .unwrap(); + assert!(first_store + .admit_calendar_due(&definition, "month-end", &runtime) + .await + .unwrap() + .is_none()); + let unchanged = sql + .query_one( + &format!( + "SELECT cursor_at,xmin::text FROM {}.calendar_cursors WHERE workflow_id=$1 AND trigger_id=$2", + namespace + ), + &[&definition.workflow.id, &"month-end"], + ) + .await + .unwrap(); + assert_eq!( + future.get::<_, chrono::DateTime>(0), + unchanged.get::<_, chrono::DateTime>(0), + "a backwards database time cannot rewind the cursor" + ); + assert_eq!( + future.get::<_, String>(1), + unchanged.get::<_, String>(1), + "an idle sweep performs no cursor update" + ); + let database_now: chrono::DateTime = sql + .query_one("SELECT transaction_timestamp()", &[]) + .await + .unwrap() + .get(0); + let schedule = CalendarSchedule::parse("5 8 L * *", "Asia/Bangkok").unwrap(); + let mut occurrence = schedule + .next_after(database_now - chrono::Duration::days(200)) + .unwrap(); + let mut due = Vec::new(); + while occurrence.scheduled_at <= database_now { + due.push(occurrence.clone()); + occurrence = schedule.next_after(occurrence.scheduled_at).unwrap(); + } + let catch_up_cursor = due[due.len() - 5].scheduled_at; + sql.execute( + &format!( + "UPDATE {}.calendar_cursors SET cursor_at=$3 WHERE workflow_id=$1 AND trigger_id=$2", + namespace + ), + &[&definition.workflow.id, &"month-end", &catch_up_cursor], + ) + .await + .unwrap(); + + let (left, right) = tokio::join!( + first_store.admit_calendar_due(&definition, "month-end", &runtime), + second_store.admit_calendar_due(&definition, "month-end", &runtime) + ); + let admissions = [left.unwrap(), right.unwrap()] + .into_iter() + .flatten() + .collect::>(); + assert_eq!(admissions.len(), 1, "replicas admit the due instant once"); + assert_eq!(admissions[0].skipped, 3); + let run = admissions[0].run_id; + let row = sql + .query_one( + &format!( + "SELECT skipped_count,(SELECT count(*) FROM {}.runs) FROM {}.calendar_cursors WHERE workflow_id=$1 AND trigger_id=$2", + namespace, namespace + ), + &[&definition.workflow.id, &"month-end"], + ) + .await + .unwrap(); + assert_eq!(row.get::<_, i64>(0), 3); + assert_eq!(row.get::<_, i64>(1), 1); + + let worker = Worker::new( + first_store.clone(), + Arc::new(NoCalls( + runtime.binding_digest_for(&definition.workflow).unwrap(), + )), + ); + assert!(worker.tick().await.unwrap()); + let finished = first_store.status(run).await.unwrap(); + assert_eq!( + finished.state, "finished", + "failure_code={:?}", + finished.failure_code + ); + let output = finished.output.unwrap(); + assert_eq!(output.as_object().unwrap().len(), 1); + assert!(output["scheduledAt"].as_str().is_some()); + + sql.execute( + &format!( + "UPDATE {}.calendar_cursors SET cursor_at=$3::timestamptz-interval '1 month' WHERE workflow_id=$1 AND trigger_id=$2", + namespace + ), + &[ + &definition.workflow.id, + &"month-end", + &admissions[0].scheduled_at, + ], + ) + .await + .unwrap(); + let replay = first_store + .admit_calendar_due(&definition, "month-end", &runtime) + .await + .unwrap() + .expect("the rewound cursor finds the same occurrence"); + assert_eq!(replay.run_id, run); + assert_eq!( + sql.query_one(&format!("SELECT count(*) FROM {}.runs", namespace), &[]) + .await + .unwrap() + .get::<_, i64>(0), + 1, + "workflow and occurrence identity admits exactly one run" + ); + + let revision_now: chrono::DateTime = sql + .query_one("SELECT transaction_timestamp()", &[]) + .await + .unwrap() + .get(0); + let prior_cursor = revision_now - chrono::Duration::days(40); + assert!(CalendarSchedule::parse("5 8 1 * *", "Asia/Bangkok") + .unwrap() + .latest_since(prior_cursor, revision_now) + .unwrap() + .latest + .is_some()); + sql.execute( + &format!( + "UPDATE {}.calendar_cursors SET workflow_revision=$3,cursor_at=$4 WHERE workflow_id=$1 AND trigger_id=$2", + namespace + ), + &[ + &definition.workflow.id, + &"month-end", + &definition.digest, + &prior_cursor, + ], + ) + .await + .unwrap(); + let mut changed_project = project(); + changed_project["workflow"]["calendarTriggers"]["month-end"]["cron"] = json!("5 8 1 * *"); + write(root.path(), &changed_project); + let changed_definition = Definition::load(root.path()).unwrap(); + assert!(first_store + .admit_calendar_due(&changed_definition, "month-end", &runtime) + .await + .unwrap() + .is_none()); + let revised = sql + .query_one( + &format!( + "SELECT workflow_revision,cursor_at,(SELECT count(*) FROM {}.runs) FROM {}.calendar_cursors WHERE workflow_id=$1 AND trigger_id=$2", + namespace, namespace + ), + &[&definition.workflow.id, &"month-end"], + ) + .await + .unwrap(); + assert_eq!(revised.get::<_, String>(0), changed_definition.digest); + assert!(revised.get::<_, chrono::DateTime>(1) >= revision_now); + assert_eq!( + revised.get::<_, i64>(2), + 1, + "a changed schedule starts observing at its new revision rather than reinterpreting prior time" + ); + + let mut locker = client(&url).await; + let admission_lock = locker.transaction().await.unwrap(); + admission_lock + .query_one( + &format!( + "SELECT id FROM {}.admission_lock WHERE id FOR UPDATE", + namespace + ), + &[], + ) + .await + .unwrap(); + let bounded = tokio::time::timeout( + std::time::Duration::from_secs(1), + first_store.admit_calendar_due(&changed_definition, "month-end", &runtime), + ) + .await + .expect("calendar lock acquisition must remain bounded"); + assert_eq!(bounded.unwrap_err().code, "store-unavailable"); + admission_lock.rollback().await.unwrap(); + + sql.execute( + &format!( + "UPDATE {}.control SET restore_hold=true WHERE id", + namespace + ), + &[], + ) + .await + .unwrap(); + assert_eq!( + first_store + .admit_calendar_due(&definition, "month-end", &runtime) + .await + .unwrap_err() + .code, + "restore-hold" + ); + sql.execute( + &format!( + "UPDATE {}.control SET restore_hold=false,active_flows_digest='calendar-replaced' WHERE id", + namespace + ), + &[], + ) + .await + .unwrap(); + assert_eq!( + first_store + .admit_calendar_due(&definition, "month-end", &runtime) + .await + .unwrap_err() + .code, + "store-unavailable", + "a moved activation fences the old calendar runner" + ); +} diff --git a/crates/registry-coordinator/tests/decision_protocol.rs b/crates/registry-coordinator/tests/decision_protocol.rs index 34f7afae5c..fe9a3ca9d1 100644 --- a/crates/registry-coordinator/tests/decision_protocol.rs +++ b/crates/registry-coordinator/tests/decision_protocol.rs @@ -641,7 +641,7 @@ async fn missing_attempt_credential_is_proven_unsent_and_retryable() { .prepare(&input) .unwrap_or_else(|_| panic!("offline prepare")); assert!( - matches!(client.call_prepared(&input, &prepared).await, CallOutcome::Retryable { code, .. } if code == "decision-unavailable") + matches!(client.call_prepared(&input, &prepared).await, CallOutcome::Retryable { code, .. } if code == "credential-unavailable") ); assert!(server.received_requests().await.unwrap().is_empty()); } diff --git a/crates/registry-coordinator/tests/deployment_postgres.rs b/crates/registry-coordinator/tests/deployment_postgres.rs index bcdcf9b1b3..4f39730e6d 100644 --- a/crates/registry-coordinator/tests/deployment_postgres.rs +++ b/crates/registry-coordinator/tests/deployment_postgres.rs @@ -565,8 +565,8 @@ async fn assert_reapply_preserves_schema_and_work( .get(0); assert!(!before["runs"].as_array().unwrap().is_empty()); assert!(!before["jobs"].as_array().unwrap().is_empty()); - assert_eq!(before["ledger"], json!([5])); - assert_eq!(before["schemaVersion"], 5); + assert_eq!(before["ledger"], json!([6])); + assert_eq!(before["schemaVersion"], 6); for _ in 0..2 { deployment::apply(migration, runtime, workflows, &[]) .await @@ -865,7 +865,7 @@ async fn split_activation_and_real_router_enforce_owned_progress_and_operator_re assert_eq!(checked["databaseId"], "router-test"); assert_eq!(checked["workflowsDigest"], workflows_digest); assert_eq!(checked["workflows"], json!({"delayed-follow-up": digest})); - assert_eq!(checked["schemaVersion"], 5); + assert_eq!(checked["schemaVersion"], 6); assert_eq!(checked["runtimeRole"], role); assert_eq!(checked["active"]["roleMode"], "split"); status_doc["deployment"]["runtimeRole"] = json!("wrong_runtime_role"); @@ -976,13 +976,13 @@ async fn split_activation_and_real_router_enforce_owned_progress_and_operator_re ); status_doc["deployment"]["packages"] = doc["deployment"]["packages"].clone(); fs::write(&status_path, serde_norway::to_string(&status_doc).unwrap()).unwrap(); - // A prior protected-state schema is refused by the Store gate before + // The immediately prior protected-state schema is refused by the Store gate before // deployment inspection can use that layout. Plan can still report its // read-only observation through the migration role. admin .batch_execute(&format!( "ALTER TABLE {namespace}.control DROP CONSTRAINT control_schema_version_check; - UPDATE {namespace}.control SET schema_version=1" + UPDATE {namespace}.control SET schema_version=5" )) .await .unwrap(); @@ -993,8 +993,8 @@ async fn split_activation_and_real_router_enforce_owned_progress_and_operator_re ); admin .batch_execute(&format!( - "UPDATE {namespace}.control SET schema_version=5; - ALTER TABLE {namespace}.control ADD CONSTRAINT control_schema_version_check CHECK(schema_version=5)" + "UPDATE {namespace}.control SET schema_version=6; + ALTER TABLE {namespace}.control ADD CONSTRAINT control_schema_version_check CHECK(schema_version=6)" )) .await .unwrap(); @@ -1003,8 +1003,8 @@ async fn split_activation_and_real_router_enforce_owned_progress_and_operator_re // write authority all refuse status while plan still reports observation. for (damage, repair) in [ ( - format!("INSERT INTO {namespace}.coordinator_migrations VALUES(6)"), - format!("DELETE FROM {namespace}.coordinator_migrations WHERE version=6"), + format!("INSERT INTO {namespace}.coordinator_migrations VALUES(7)"), + format!("DELETE FROM {namespace}.coordinator_migrations WHERE version=7"), ), ( format!("REVOKE SELECT ON {namespace}.jobs FROM {role}"), diff --git a/crates/registry-coordinator/tests/postgres_state.rs b/crates/registry-coordinator/tests/postgres_state.rs index 70d8f4249a..3c54b8ece8 100644 --- a/crates/registry-coordinator/tests/postgres_state.rs +++ b/crates/registry-coordinator/tests/postgres_state.rs @@ -335,6 +335,16 @@ async fn declared_refusal_harness_with(workflow: &str) -> Harness { h.definition = Definition::load(h.project.path()).expect("valid declared-refusal workflow"); h } + +async fn read_failure_harness() -> Harness { + let mut h = harness().await; + let workflow = WORKFLOW + .replace(" next: choose\n choose:", " next: choose\n onFailure: unavailable\n unavailable:\n type: finish\n outcome: unavailable\n choose:") + .replace(" withdrawn:\n type: 'null'", " withdrawn:\n type: 'null'\n unavailable:\n type: 'null'"); + std::fs::write(h.project.path().join("workflow.yaml"), workflow).unwrap(); + h.definition = Definition::load(h.project.path()).expect("valid read failure workflow"); + h +} fn input(at: chrono::DateTime) -> Value { json!({"at":at.to_rfc3339(), "id":"synthetic-contact-canary"}) } @@ -479,6 +489,27 @@ struct Fake { entered: Notify, release: Option, } + +struct ReadFailureFake { + outcome: Mutex>, + calls: Mutex>, +} + +#[async_trait] +impl AdapterSet for ReadFailureFake { + fn binding_digest(&self) -> &str { + "binding-a" + } + + async fn call(&self, request: &CallRequest) -> CallOutcome { + self.calls.lock().unwrap().push(request.clone()); + self.outcome + .lock() + .unwrap() + .take() + .expect("one synthetic read outcome") + } +} impl Fake { fn new(outcomes: Vec) -> Arc { Arc::new(Self { @@ -840,6 +871,92 @@ async fn retryable_calls_back_off_from_the_pause_hint_and_never_past_the_run_dea } } +#[tokio::test] +async fn an_exhausted_read_routes_after_restart_with_a_bounded_category_and_no_output() { + let h = read_failure_harness().await; + let run = h.admit().await; + let setup = Worker::new(h.store.clone(), Fake::new(vec![])); + assert!( + setup.tick().await.unwrap(), + "the timer advances to the read" + ); + h.sql("UPDATE {schema}.jobs SET attempt=9 WHERE step='read'") + .await; + drop(setup); + + let reopened = Arc::new(Store::connect(&h.url, &h.namespace).await.unwrap()); + let fake = Arc::new(ReadFailureFake { + outcome: Mutex::new(Some(CallOutcome::Retryable { + code: "transport-unavailable".into(), + retry_after: None, + })), + calls: Mutex::new(vec![]), + }); + let restarted = Worker::new(reopened.clone(), fake.clone()); + assert!( + restarted.tick().await.unwrap(), + "the final read attempt runs" + ); + let routed = reopened.status(run).await.unwrap(); + assert_eq!(routed.state, "running"); + assert_eq!(routed.step, "unavailable"); + assert_eq!( + routed.failure_code.as_deref(), + Some("transport-unavailable") + ); + assert_eq!(h.output_values(run).await.get("read"), None); + assert_eq!(fake.calls.lock().unwrap().len(), 1); + + assert!( + restarted.tick().await.unwrap(), + "the failure route finishes" + ); + let finished = reopened.status(run).await.unwrap(); + assert_eq!(finished.state, "finished"); + assert_eq!(finished.outcome.as_deref(), Some("unavailable")); + assert_eq!(finished.output, Some(Value::Null)); + assert_eq!(fake.calls.lock().unwrap().len(), 1); + let inspection = reopened.inspect(run, "binding-a").await.unwrap(); + let read = inspection + .steps + .iter() + .find(|step| step.step == "read") + .unwrap(); + assert_eq!(read.failure_code.as_deref(), Some("transport-unavailable")); +} + +#[tokio::test] +async fn an_authorization_refusal_never_takes_a_read_failure_route() { + let h = read_failure_harness().await; + let run = h.admit().await; + let setup = Worker::new(h.store.clone(), Fake::new(vec![])); + assert!( + setup.tick().await.unwrap(), + "the timer advances to the read" + ); + h.sql("UPDATE {schema}.jobs SET attempt=9 WHERE step='read'") + .await; + + let fake = Arc::new(ReadFailureFake { + outcome: Mutex::new(Some(CallOutcome::Refused { + code: "product-forbidden".into(), + })), + calls: Mutex::new(vec![]), + }); + let worker = Worker::new(h.store.clone(), fake.clone()); + assert!(worker.tick().await.unwrap(), "the refused read is recorded"); + let stopped = h.store.status(run).await.unwrap(); + assert_eq!(stopped.state, "failed"); + assert_eq!(stopped.step, "read"); + assert_eq!(stopped.failure_code.as_deref(), Some("product-forbidden")); + assert_eq!(h.output_values(run).await.get("read"), None); + assert_eq!(fake.calls.lock().unwrap().len(), 1); + assert!( + !worker.tick().await.unwrap(), + "the refusal queues no fallback" + ); +} + #[tokio::test] async fn a_call_step_makes_ten_attempts_with_doubling_backoff_before_its_run_fails() { let unavailable = || CallOutcome::Retryable { @@ -898,12 +1015,22 @@ struct ExternalDirectory { run: Uuid, } async fn external_directory() -> ExternalDirectory { + external_directory_with_failure_edge(false).await +} +async fn external_directory_with_failure_edge(on_failure: bool) -> ExternalDirectory { let mut h = harness().await; - std::fs::write( - h.project.path().join("workflow.yaml"), - include_str!("../../../products/coordinator/examples/external-directory/workflow.yaml"), - ) - .unwrap(); + let workflow = + include_str!("../../../products/coordinator/examples/external-directory/workflow.yaml"); + let workflow = if on_failure { + workflow.replacen( + " next: result\n", + " next: result\n onFailure: unavailable\n", + 1, + ) + } else { + workflow.to_owned() + }; + std::fs::write(h.project.path().join("workflow.yaml"), workflow).unwrap(); std::fs::write( h.project.path().join("functions.rhai"), include_str!("../../../products/coordinator/examples/external-directory/functions.rhai"), @@ -1066,6 +1193,38 @@ async fn a_gateway_busy_on_every_attempt_reaches_the_workflow_on_the_last_one() assert_eq!(e.lookups().await, 10); } +#[tokio::test] +async fn an_authored_failure_edge_keeps_an_exhausted_external_get_retryable() { + let routed = external_directory_with_failure_edge(true).await; + mount_directory(503, 10).mount(&routed.server).await; + for attempt in 1..=9 { + assert!( + routed.worker.tick().await.unwrap(), + "attempt {attempt} runs" + ); + routed.lookup_due_now().await; + } + + assert!( + routed.worker.tick().await.unwrap(), + "the final lookup attempt runs" + ); + let status = routed.h.store.status(routed.run).await.unwrap(); + assert_eq!(status.state, "running"); + assert_eq!(status.step, "unavailable"); + assert_eq!(status.failure_code.as_deref(), Some("external-unavailable")); + assert_eq!(routed.h.output_values(routed.run).await.get("lookup"), None); + assert_eq!(routed.lookups().await, 10); + + assert!( + routed.worker.tick().await.unwrap(), + "the failure route finishes" + ); + let finished = routed.h.store.status(routed.run).await.unwrap(); + assert_eq!(finished.state, "finished"); + assert_eq!(finished.outcome.as_deref(), Some("directory-unavailable")); +} + #[tokio::test] async fn other_directory_statuses_reach_the_workflow_on_the_first_attempt() { for (status, outcome) in [(404, "no-directory-entry"), (500, "directory-unavailable")] { diff --git a/crates/registry-coordinator/tests/review_wait_postgres.rs b/crates/registry-coordinator/tests/review_wait_postgres.rs index d79a95a348..32c7679194 100644 --- a/crates/registry-coordinator/tests/review_wait_postgres.rs +++ b/crates/registry-coordinator/tests/review_wait_postgres.rs @@ -809,6 +809,61 @@ async fn a_result_found_by_the_last_read_wins_over_the_timeout() { ); } +#[tokio::test] +async fn the_service_deadline_reads_the_result_before_taking_its_route() { + for (last, expected) in [ + ( + CompletionObservation::Settled(result("approved")), + "notice-sent", + ), + ( + CompletionObservation::Pending { retry_after: None }, + "service-expired", + ), + ] { + let mut h = harness(true).await; + let mut authored = registry_platform_yaml::Reader::new("workflow.yaml") + .scan(workflow_with_cancel(true, false, false).as_bytes()) + .unwrap() + .unwrap() + .to_json_value(); + let workflow = &mut authored["workflow"]; + workflow["serviceDeadlineSeconds"] = json!(60); + workflow["onDeadline"] = json!("service-expired"); + workflow["steps"]["service-expired"] = json!({"type":"finish","outcome":"service-expired"}); + workflow["outcomes"]["service-expired"] = json!({"type":"null"}); + std::fs::write( + h._project.path().join("workflow.yaml"), + authored.to_string(), + ) + .unwrap(); + h.definition = Definition::load(h._project.path()).unwrap(); + let fake = Fake::new(vec![ + CompletionObservation::Pending { retry_after: None }, + last, + ]); + let worker = Worker::new(h.store.clone(), fake.clone()); + let run = h.admit().await; + h.drain(&worker).await; + assert_eq!(fake.reads(), 1); + let before = h.store.inspect(run, "binding-a").await.unwrap(); + assert_eq!( + before.waits[0].timeout_at, + before.run.admitted_at + chrono::Duration::seconds(60) + ); + h.sql("WITH due AS (UPDATE {schema}.runs SET service_deadline_at=clock_timestamp()-interval '1 second' RETURNING run_id,service_deadline_at) UPDATE {schema}.completion_waits w SET timeout_at=due.service_deadline_at,started_at=due.service_deadline_at-interval '60 seconds' FROM due WHERE w.run_id=due.run_id").await; + h.make_due(run).await; + h.drain(&worker).await; + assert_eq!(fake.reads(), 2); + assert_eq!( + status(&h, run).await, + ("finished".into(), Some(expected.into()), None) + ); + assert!(!fake.operations().contains(&Operation::CancelReviewRequest)); + assert!(h.wait_row(run).await.is_none()); + } +} + #[tokio::test] async fn a_read_shortly_before_the_timeout_schedules_the_last_read_at_the_timeout() { let h = harness(true).await; diff --git a/crates/registry-coordinator/tests/service_deadline.rs b/crates/registry-coordinator/tests/service_deadline.rs new file mode 100644 index 0000000000..6741fc2add --- /dev/null +++ b/crates/registry-coordinator/tests/service_deadline.rs @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_coordinator::definition::Definition; +use serde_json::{json, Value}; +use std::fs; + +fn project() -> Value { + json!({ + "apiVersion": "id.registrystack.org/formats/coordinator/project/v1alpha1", + "kind": "CoordinatorProject", + "project": {"id": "service-deadline", "version": "1"}, + "workflow": { + "input": { + "type": "object", + "properties": {"at": {"type": "string", "format": "date-time"}}, + "required": ["at"], + "additionalProperties": false + }, + "deadlineSeconds": 3600, + "serviceDeadlineSeconds": 60, + "onDeadline": "escalate", + "start": "wait", + "steps": { + "wait": {"type": "wait-until", "waitUntil": {"type": "input", "path": "/at"}, "next": "done"}, + "done": {"type": "finish", "outcome": "done"}, + "escalate": {"type": "finish", "outcome": "late"} + }, + "outcomes": {"done": {"type": "null"}, "late": {"type": "null"}} + } + }) +} + +fn write(root: &std::path::Path, project: &Value) { + fs::write(root.join("workflow.yaml"), project.to_string()).unwrap(); +} + +#[test] +fn service_deadline_is_checked_explained_and_pinned() { + let root = tempfile::tempdir().unwrap(); + let mut value = project(); + write(root.path(), &value); + let definition = Definition::load(root.path()).expect("check accepts a separate deadline"); + let report = definition.explain().unwrap(); + assert_eq!(report["serviceDeadlineSeconds"], 60); + assert_eq!(report["onDeadline"], "escalate"); + let restored = Definition::from_snapshot(&definition.snapshot().unwrap()).unwrap(); + assert_eq!(restored.digest, definition.digest); + assert_eq!(restored.explain().unwrap()["onDeadline"], "escalate"); + let scenario_path = root.path().join("scenarios.yaml"); + fs::write( + &scenario_path, + json!({ + "apiVersion": registry_coordinator::scenarios::API_VERSION, + "kind": registry_coordinator::scenarios::KIND, + "cases": [{ + "id": "late", + "admittedAt": "2026-11-10T00:00:00Z", + "input": {"at": "2026-11-10T00:10:00Z"}, + "replies": {}, + "expect": { + "path": ["wait", "escalate"], + "state": "completed", + "outcome": {"type": "named", "value": "late"} + } + }] + }) + .to_string(), + ) + .unwrap(); + let scenarios = registry_coordinator::scenarios::load(&scenario_path).unwrap(); + let result = registry_coordinator::scenarios::run(&definition, &scenarios.cases[0]).unwrap(); + assert_eq!(result.path, ["wait", "escalate"]); + assert_eq!(result.outcome.as_deref(), Some("late")); + assert_eq!(result.elapsed_seconds, 60); + + for target in ["missing", "wait", "done"] { + value["workflow"]["onDeadline"] = json!(target); + write(root.path(), &value); + let error = Definition::load(root.path()) + .err() + .expect("invalid deadline edge"); + assert!(format!("{error:?}").contains("onDeadline"), "{error:?}"); + } +} + +#[cfg(feature = "postgres-test")] +#[tokio::test] +async fn a_wait_crosses_the_service_deadline_once_across_restart() { + use async_trait::async_trait; + use chrono::{Duration, Utc}; + use registry_coordinator::{ + protocol::{AdapterSet, CallOutcome, CallRequest}, + store::Store, + worker::Worker, + }; + use std::sync::Arc; + use uuid::Uuid; + + struct Local; + #[async_trait] + impl AdapterSet for Local { + fn binding_digest(&self) -> &str { + "local" + } + async fn call(&self, _: &CallRequest) -> CallOutcome { + panic!("the test workflow has no external operations") + } + } + + let url = std::env::var("COORDINATOR_TEST_DATABASE_URL") + .expect("use a disposable local PostgreSQL database"); + let namespace = format!("coordinator_{}", Uuid::new_v4().simple()); + let store = Arc::new(Store::connect(&url, &namespace).await.unwrap()); + store.migrate().await.unwrap(); + let root = tempfile::tempdir().unwrap(); + write(root.path(), &project()); + let definition = Definition::load(root.path()).unwrap(); + let admit = |at: chrono::DateTime| json!({"at": at.to_rfc3339()}); + let early = store + .admit( + &definition, + admit(Utc::now() - Duration::minutes(1)), + "p", + "early", + "local", + ) + .await + .unwrap(); + let worker = Worker::new(store.clone(), Arc::new(Local)); + assert!(worker.tick().await.unwrap()); + assert!(worker.tick().await.unwrap()); + assert_eq!( + store.status(early).await.unwrap().outcome.as_deref(), + Some("done") + ); + + let late = store + .admit( + &definition, + admit(Utc::now() + Duration::hours(1)), + "p", + "late", + "local", + ) + .await + .unwrap(); + let status = store.status(late).await.unwrap(); + assert_eq!( + status.next_due_at, + Some(status.admitted_at + Duration::seconds(60)) + ); + assert!(!worker.tick().await.unwrap()); + drop(worker); + + let (db, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls) + .await + .unwrap(); + let connection = tokio::spawn(connection); + db.execute( + &format!( + "UPDATE {namespace}.runs SET service_deadline_at=clock_timestamp() WHERE run_id=$1" + ), + &[&late], + ) + .await + .unwrap(); + db.execute( + &format!("UPDATE {namespace}.jobs SET next_attempt_at=clock_timestamp() WHERE run_id=$1 AND state='pending'"), + &[&late], + ).await.unwrap(); + let reopened = Arc::new(Store::connect(&url, &namespace).await.unwrap()); + let worker = Worker::new(reopened.clone(), Arc::new(Local)); + assert!(worker.tick().await.unwrap()); + assert_eq!(reopened.status(late).await.unwrap().step, "escalate"); + assert!(worker.tick().await.unwrap()); + assert_eq!( + reopened.status(late).await.unwrap().outcome.as_deref(), + Some("late") + ); + assert!(!worker.tick().await.unwrap()); + assert_eq!( + reopened.status(early).await.unwrap().outcome.as_deref(), + Some("done") + ); + drop(db); + connection.await.unwrap().unwrap(); +} diff --git a/crates/registry-coordinator/tests/support/state_schema.rs b/crates/registry-coordinator/tests/support/state_schema.rs index fd6e857f2c..c2e1d8bd23 100644 --- a/crates/registry-coordinator/tests/support/state_schema.rs +++ b/crates/registry-coordinator/tests/support/state_schema.rs @@ -51,7 +51,7 @@ async fn a_second_migration_changes_no_schema_object_or_protected_row() { assert_eq!(catalog(&h).await, before, "the second migration ran DDL"); assert_eq!(restored_current_job(&h, run).await, job); assert_eq!(restored_lease_identity(&h, run).await, identity); - assert_eq!(h.store.doctor().await.unwrap().schema_version, 5); + assert_eq!(h.store.doctor().await.unwrap().schema_version, 6); } #[tokio::test] diff --git a/crates/registry-platform-calendar/README.md b/crates/registry-platform-calendar/README.md index 2bacd9ac31..7095281df9 100644 --- a/crates/registry-platform-calendar/README.md +++ b/crates/registry-platform-calendar/README.md @@ -18,6 +18,11 @@ explicit authority, names the closure it reopens, and stays inside it. IANA timezone. A product that answers "today" uses it so that one deployment has one today, which changes at local midnight. +`resolve_local_instant(date, time, timezone)` applies the shared calculated- +instant rule to a product-selected local date and time. The product still owns +why that wall time was selected and whether it is a deadline, trigger, or other +commitment. + Daylight-saving transitions follow one rule per evaluator, with no knob. A working-day deadline is a single calculated instant: a due time inside a gap moves to the first instant after the gap, and a due time inside a fold takes diff --git a/crates/registry-platform-calendar/src/lib.rs b/crates/registry-platform-calendar/src/lib.rs index 620e97554d..08fd64239c 100644 --- a/crates/registry-platform-calendar/src/lib.rs +++ b/crates/registry-platform-calendar/src/lib.rs @@ -164,6 +164,24 @@ fn resolved_local_instant( } } +/// Convert one calculated local wall time into an exact instant in a named +/// IANA timezone. +/// +/// This is the shared single-instant daylight-saving rule: a local time in a +/// gap moves to the first instant after the gap, and a local time in a fold +/// takes the earlier of its two instants. Products retain the policy that +/// decides which local date and time to evaluate. +pub fn resolve_local_instant( + date: NaiveDate, + time: NaiveTime, + timezone: &str, +) -> Result, CalendarEvaluationError> { + let timezone = timezone + .parse::() + .map_err(|_| CalendarEvaluationError::InvalidTimezone)?; + resolved_local_instant(date, time, timezone) +} + /// The earliest whole-second instant whose wall time in `timezone` is at or /// after `local`. For a local time inside a gap, that is the instant the gap /// ends. No UTC offset reaches a day, so the instant lies within a day of @@ -289,6 +307,34 @@ fn day_interval( mod tests { use super::*; + #[test] + fn a_named_zone_local_instant_uses_the_shared_gap_and_fold_rule() { + let gap = resolve_local_instant( + NaiveDate::from_ymd_opt(2026, 3, 8).unwrap(), + NaiveTime::from_hms_opt(2, 30, 0).unwrap(), + "America/New_York", + ) + .expect("a gap moves to its first real instant"); + assert_eq!(gap, instant("2026-03-08T07:00:00Z")); + + let fold = resolve_local_instant( + NaiveDate::from_ymd_opt(2026, 11, 1).unwrap(), + NaiveTime::from_hms_opt(1, 30, 0).unwrap(), + "America/New_York", + ) + .expect("a fold chooses its earlier instant"); + assert_eq!(fold, instant("2026-11-01T05:30:00Z")); + + assert_eq!( + resolve_local_instant( + NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), + NaiveTime::MIN, + "Not/A_Zone", + ), + Err(CalendarEvaluationError::InvalidTimezone) + ); + } + #[test] fn end_of_day_boundaries_preserve_actual_dst_day_lengths() { for (date, start, end, minutes) in [ diff --git a/crates/registry-platform-sqlite/src/schema.rs b/crates/registry-platform-sqlite/src/schema.rs index 11b8fdaed9..6b01e4b899 100644 --- a/crates/registry-platform-sqlite/src/schema.rs +++ b/crates/registry-platform-sqlite/src/schema.rs @@ -85,13 +85,13 @@ pub fn inspect_schema( ) -> Result { limits.validate()?; profile_confirm(profile)?; - let connection = open_for_schema(profile)?; + let mut connection = open_for_schema(profile)?; // Establish one read snapshot. The only SQL below is fixed schema-catalog // inspection; no caller SQL or raw connection crosses this boundary. connection .execute_batch("BEGIN") .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; - let outcome = collect_schema(&connection, limits); + let outcome = collect_schema(&mut connection, limits); profile_confirm(profile)?; confirm_connection_still_bound(&connection)?; drop(connection); @@ -141,7 +141,7 @@ fn open_for_schema(profile: &DatabaseProfile) -> Result } fn collect_schema( - connection: &Connection, + connection: &mut Connection, limits: &InspectionLimits, ) -> Result, SqliteError> { const WITHIN: u8 = 0; diff --git a/crates/registry-platform-sqlite/src/statement.rs b/crates/registry-platform-sqlite/src/statement.rs index 56e73afac5..e8df01fb8f 100644 --- a/crates/registry-platform-sqlite/src/statement.rs +++ b/crates/registry-platform-sqlite/src/statement.rs @@ -322,10 +322,10 @@ impl ReadOnlyStatement { } profile.confirm()?; ensure_before(deadline)?; - let connections = + let mut connections = open_connection_pool_before(&profile, contract.limits.concurrency, Some(deadline))?; let first = connections - .first() + .first_mut() .ok_or_else(|| SqliteError::new(ErrorKind::InvalidPlan))?; ensure_before(deadline)?; let parameters = verify_statement(first, &contract)?; @@ -392,7 +392,7 @@ impl ReadOnlyStatement { .await .map_err(|_| SqliteError::new(ErrorKind::Timeout))? .map_err(|_| SqliteError::new(ErrorKind::Concurrency))?; - let connection = self + let mut connection = self .connections .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -403,7 +403,7 @@ impl ReadOnlyStatement { let profile = self.profile.clone(); let execution = tokio::task::spawn_blocking(move || { let execution = - execute_on_connection(&profile, &connection, &plan, &bindings, deadline); + execute_on_connection(&profile, &mut connection, &plan, &bindings, deadline); if return_or_replace_connection(&pool, &profile, connection, execution.reusable) { drop(permit); } else { @@ -441,14 +441,19 @@ impl ReadOnlyStatement { let bindings = bind_values(&self.plan.parameters, values)?; let deadline = bounded_deadline(self.plan.limits.timeout, caller_deadline)?; ensure_before(deadline)?; - let connection = self + let mut connection = self .connections .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .pop() .ok_or_else(|| SqliteError::new(ErrorKind::WorkerUnavailable))?; - let execution = - execute_on_connection(&self.profile, &connection, &self.plan, &bindings, deadline); + let execution = execute_on_connection( + &self.profile, + &mut connection, + &self.plan, + &bindings, + deadline, + ); let restored = return_or_replace_connection( &self.connections, &self.profile, @@ -502,9 +507,10 @@ pub fn check_statement_offline(contract: &StatementContract) -> Result<(), Sqlit if contains_positional_parameter(&contract.sql) { return Err(SqliteError::new(ErrorKind::UndeclaredParameter)); } - let connection = + let mut connection = Connection::open_in_memory().map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; - install_authorizer(&connection).map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; + install_authorizer(&mut connection) + .map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; match connection.prepare(&contract.sql).map(|_| ()) { Ok(()) => Ok(()), Err(error) => { @@ -527,12 +533,12 @@ fn open_connection(profile: &DatabaseProfile) -> Result let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX; - let connection = Connection::open_with_flags(uri, flags) + let mut connection = Connection::open_with_flags(uri, flags) .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; connection .set_limit(Limit::SQLITE_LIMIT_LENGTH, MAXIMUM_ENGINE_VALUE_BYTES) .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; - install_authorizer(&connection) + install_authorizer(&mut connection) .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; confirm_connection_still_bound(&connection)?; Ok(connection) @@ -573,7 +579,7 @@ fn confirm_connection_pool_still_bound(connections: &[Connection]) -> Result<(), fn execute_on_connection( profile: &DatabaseProfile, - connection: &Connection, + connection: &mut Connection, plan: &CompiledPlan, bindings: &[(usize, Value)], deadline: Instant, @@ -590,7 +596,7 @@ fn execute_on_connection( fn execute_on_connection_with_post_statement_hook( profile: &DatabaseProfile, - connection: &Connection, + connection: &mut Connection, plan: &CompiledPlan, bindings: &[(usize, Value)], deadline: Instant, @@ -713,7 +719,7 @@ pub(crate) fn database_uri(path: &Path, immutable: bool) -> Option { Some(uri) } -pub(crate) fn install_authorizer(connection: &Connection) -> rusqlite::Result<()> { +pub(crate) fn install_authorizer(connection: &mut Connection) -> rusqlite::Result<()> { connection.authorizer(Some(|context: AuthContext<'_>| authorize(&context.action))) } @@ -903,7 +909,7 @@ const BUDGET_STEPS: u8 = 1; const BUDGET_TIME: u8 = 2; fn install_progress_handler( - connection: &Connection, + connection: &mut Connection, steps: u64, deadline: Instant, ) -> Result, SqliteError> { @@ -932,7 +938,7 @@ fn install_progress_handler( } fn run_statement( - connection: &Connection, + connection: &mut Connection, plan: &CompiledPlan, bindings: &[(usize, Value)], deadline: Instant, @@ -952,7 +958,7 @@ fn run_statement( } fn run_statement_in_transaction( - connection: &Connection, + connection: &mut Connection, plan: &CompiledPlan, bindings: &[(usize, Value)], deadline: Instant, @@ -1005,7 +1011,7 @@ fn run_statement_in_transaction( Ok((collected, schema_fingerprint)) } -fn begin_read_transaction(connection: &Connection) -> Result<(), SqliteError> { +fn begin_read_transaction(connection: &mut Connection) -> Result<(), SqliteError> { connection .authorizer(None::) -> Authorization>) .map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; @@ -1017,7 +1023,7 @@ fn begin_read_transaction(connection: &Connection) -> Result<(), SqliteError> { Ok(()) } -fn reset_connection_after_read(connection: &Connection) -> Result<(), SqliteError> { +fn reset_connection_after_read(connection: &mut Connection) -> Result<(), SqliteError> { // Every reset is attempted even when an earlier one fails. In particular, // an authorizer-installation failure must not skip rollback, and a rollback // failure must not leave the connection without the reviewed authorizer. @@ -1039,7 +1045,7 @@ fn reset_connection_after_read(connection: &Connection) -> Result<(), SqliteErro } fn verify_schema_at_open( - connection: &Connection, + connection: &mut Connection, binding: Option<&SchemaBinding>, limits: &StatementLimits, deadline: Instant, @@ -1061,7 +1067,7 @@ fn verify_schema_at_open( } fn verify_schema_in_transaction( - connection: &Connection, + connection: &mut Connection, plan: &CompiledPlan, budget: &AtomicU8, ) -> Result, SqliteError> { @@ -1072,7 +1078,7 @@ fn verify_schema_in_transaction( } fn schema_fingerprint_with_budget( - connection: &Connection, + connection: &mut Connection, binding: &SchemaBinding, statement_limits: &StatementLimits, budget: &AtomicU8, @@ -1346,11 +1352,11 @@ mod tests { } fn reusable_test_connection() -> Connection { - let connection = Connection::open_in_memory().expect("test connection opens"); + let mut connection = Connection::open_in_memory().expect("test connection opens"); connection .set_limit(Limit::SQLITE_LIMIT_LENGTH, MAXIMUM_ENGINE_VALUE_BYTES) .expect("engine limit installs"); - install_authorizer(&connection).expect("reviewed authorizer installs"); + install_authorizer(&mut connection).expect("reviewed authorizer installs"); connection } @@ -1422,7 +1428,7 @@ mod tests { } fn assert_failure_leaves_connection_reusable( - connection: &Connection, + connection: &mut Connection, plan: &CompiledPlan, deadline: Instant, expected: ErrorKind, @@ -1538,10 +1544,11 @@ mod tests { database(&path, "governed"); let profile = DatabaseProfile::Snapshot(CapturedSnapshot::capture(&path).expect("snapshot captures")); - let connection = open_replacement_connection(&profile).expect("snapshot connection opens"); + let mut connection = + open_replacement_connection(&profile).expect("snapshot connection opens"); let execution = execute_on_connection_with_post_statement_hook( &profile, - &connection, + &mut connection, &test_plan("SELECT id FROM records", 100_000), &[], Instant::now() + Duration::from_secs(1), @@ -1570,9 +1577,9 @@ mod tests { #[test] fn step_deadline_engine_and_authorizer_failures_leave_connection_reusable() { - let connection = reusable_test_connection(); + let mut connection = reusable_test_connection(); assert_failure_leaves_connection_reusable( - &connection, + &mut connection, &test_plan( "WITH RECURSIVE counter(n) AS (\ SELECT 1 UNION ALL SELECT n + 1 FROM counter WHERE n < 50000000\ @@ -1583,19 +1590,19 @@ mod tests { ErrorKind::StepBudgetExceeded, ); assert_failure_leaves_connection_reusable( - &connection, + &mut connection, &test_plan("SELECT 'too-late' AS id", 100_000), Instant::now(), ErrorKind::TimeBudgetExceeded, ); assert_failure_leaves_connection_reusable( - &connection, + &mut connection, &test_plan("SELECT json_extract('not-json', '$') AS id", 100_000), Instant::now() + Duration::from_secs(1), ErrorKind::ExecutionFailed, ); assert_failure_leaves_connection_reusable( - &connection, + &mut connection, &test_plan("SELECT random() AS id", 100_000), Instant::now() + Duration::from_secs(1), ErrorKind::AuthorizerRefused, @@ -1623,7 +1630,7 @@ mod tests { false, )); - let replacement = connections + let mut replacement = connections .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .pop() @@ -1635,7 +1642,7 @@ mod tests { > 1 ); let answered = run_statement( - &replacement, + &mut replacement, &test_plan("SELECT 'replacement' AS id", 100_000), &[], Instant::now() + Duration::from_secs(1), diff --git a/crates/registry-stack-client-node/casework/client.d.ts b/crates/registry-stack-client-node/casework/client.d.ts index 6b894d7497..c551334dc1 100644 --- a/crates/registry-stack-client-node/casework/client.d.ts +++ b/crates/registry-stack-client-node/casework/client.d.ts @@ -364,7 +364,7 @@ export interface ReviewAccountabilityRecord { occurredAt: string; retainedUntil: string decisionReceipt?: ReviewDecisionReceipt } -export interface ReviewStagePolicy { id: string; label?: string; queue: string; decidingProfiles: ReadonlyArray; requiredApprovals: SafeInteger; negativeDecisions?: 'settle' | 'advise'; excludeInitiator?: boolean; excludePreviousStageReviewers?: boolean } +export interface ReviewStagePolicy { id: string; label?: string; queue: string; decidingProfiles: ReadonlyArray; requiredApprovals: SafeInteger; outcomes?: ReadonlyArray; negativeDecisions?: 'settle' | 'advise'; excludeInitiator?: boolean; excludePreviousStageReviewers?: boolean } export interface ReviewRetentionPolicy { terminalRetentionDays: SafeInteger; accountabilityRetentionDays: SafeInteger } export interface ReviewContextContract { id: string; revision: ContentDigest; schemaDigest: ContentDigest } export type ReviewContextPolicy = { mode: 'submitted' } | { mode: 'current-source'; provider: string; contract: ReviewContextContract } diff --git a/products/breg/TASK_GRANTS.md b/products/breg/TASK_GRANTS.md index c68197dc7e..e306ffc590 100644 --- a/products/breg/TASK_GRANTS.md +++ b/products/breg/TASK_GRANTS.md @@ -188,6 +188,12 @@ retained task subjects, under the existing operator retention boundary. ## Audit +Request audit records include an activation-scoped keyed `clientReference` +when the bearer verifier matches an allowed OAuth client. It uses the same +pseudonym as `authorization.clientPseudonym` and distinguishes two clients +acting for the same principal. An unrestricted verifier has no matched client +to record. Client identifiers and tokens stay out of the journal. + Terminal and refusal records for a request carrying a verified task grant include an `authorization` object using the shared authorization audit fields. Grant, principal, client and approver identifiers are keyed pseudonyms, scoped to the @@ -202,8 +208,8 @@ the keyed pseudonym of `act.sub`, scoped to the package revision like the other identifiers. `principalPseudonym` then names the person the agent acts for and `clientPseudonym` the agent's client. A standing agent carries no grant, so its object has no grant, approver, source issuer or deadline. The raw actor -identifier never reaches the journal. A task-grant token without `act` records -exactly what it did before. +identifier never reaches the journal. A task-grant token without `act` keeps +the same `authorization` fields. Both kinds of token also carry the object on a read's terminal record: record reads, lists and lookups, revision reads, and history and snapshot reads. A diff --git a/products/breg/contracts/security-invariant-matrix.yaml b/products/breg/contracts/security-invariant-matrix.yaml index 151b20d69b..8b6c3b8657 100644 --- a/products/breg/contracts/security-invariant-matrix.yaml +++ b/products/breg/contracts/security-invariant-matrix.yaml @@ -214,3 +214,4 @@ invariants: - {"id": "BREG-SEC-201", "state": "enforced", "targetWave": "W5", "threat": "A crashed, resumed, paused, adopted, or superseded timer worker settles an occurrence after a peer or newer activation, control epoch, instance epoch, claim epoch, or claim owner took authority.", "enforcementPoint": "Every completion, refusal, technical settlement, retry transition, and release compares the full current marker, binding incarnation and semantic revisions, active package and activation, control and instance epochs, claim owner, claim epoch, token, and lease in one fenced ledger transition.", "refusal": "A stale owner changes zero Record, history, receipt, outbox, occurrence, attempt, or allowance rows; only the current fenced owner may append the terminal or retry outcome.", "negativeId": "BREG-NEG-201", "negativeTest": {"path": "crates/registry-breg/tests/postgres_record_timer_native.rs", "name": "native_stale_outcome_technical_failure_cannot_overwrite_peer"}} # Audited operator transition proof: crates/registry-breg/tests/postgres_record_timer_operator.rs::operator_retry_grants_one_technical_allowance_and_refuses_other_states - {"id": "BREG-SEC-202", "state": "enforced", "targetWave": "W5", "threat": "A timer operator read discloses Record values or an unaudited operator command changes timer control or retry state outside the current package and ledger fences.", "enforcementPoint": "Read-only operator transactions project a closed metadata-only occurrence and attempt model under verified active-package admission; pause, resume, and retry use paired value-free audit records and exact control, occurrence, attempt, binding, window, and state comparisons.", "refusal": "Return no Record field values, raw failure details, claim tokens, or unbounded history; refuse stale or ineligible operator transitions without mutation; refuse before mutation when the audit request cannot be recorded, and withhold a successful response as unresolved or unfinished when commit acknowledgement or the response audit is uncertain, without claiming reversal of a transition that may have committed.", "negativeId": "BREG-NEG-202", "negativeTest": {"path": "crates/registry-breg/tests/postgres_record_timer_operator.rs", "name": "operator_reads_pending_and_completed_occurrences_without_record_values"}} + - {id: BREG-SEC-203, state: enforced, targetWave: W5, threat: "Requests from different OAuth clients for one principal are indistinguishable in the journal or expose client or bearer values.", enforcementPoint: "BReg request audit projection receives the OAuth client matched by the bearer verifier.", refusal: "Project an activation-scoped keyed client reference on attempts, terminal records, and refusals. Omit it when no client is matched and retain no raw client identifier or token.", negativeId: BREG-NEG-203, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: direct_client_is_distinguished_in_allowed_and_refused_request_audit}} diff --git a/products/breg/contracts/security-test-traceability.yaml b/products/breg/contracts/security-test-traceability.yaml index 23dd3ea5c0..60c6ccc316 100644 --- a/products/breg/contracts/security-test-traceability.yaml +++ b/products/breg/contracts/security-test-traceability.yaml @@ -217,3 +217,4 @@ traceability: - {"id": "BREG-SEC-201", "state": "enforced", "negativeId": "BREG-NEG-201", "negativeTest": {"path": "crates/registry-breg/tests/postgres_record_timer_native.rs", "name": "native_stale_outcome_technical_failure_cannot_overwrite_peer"}} # Audited operator transition proof: crates/registry-breg/tests/postgres_record_timer_operator.rs::operator_retry_grants_one_technical_allowance_and_refuses_other_states. Encrypted-value non-disclosure proof: crates/registry-breg/tests/postgres_record_timers.rs::encrypted_predicate_write_opens_the_same_occurrence_and_maps_current_plaintext - {"id": "BREG-SEC-202", "state": "enforced", "negativeId": "BREG-NEG-202", "negativeTest": {"path": "crates/registry-breg/tests/postgres_record_timer_operator.rs", "name": "operator_reads_pending_and_completed_occurrences_without_record_values"}} + - {id: BREG-SEC-203, state: enforced, negativeId: BREG-NEG-203, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: direct_client_is_distinguished_in_allowed_and_refused_request_audit}} diff --git a/products/breg/fixtures/action-plan-branches/README.md b/products/breg/fixtures/action-plan-branches/README.md index bb66e3b4a9..8d992ef53f 100644 --- a/products/breg/fixtures/action-plan-branches/README.md +++ b/products/breg/fixtures/action-plan-branches/README.md @@ -11,6 +11,7 @@ bregctl check products/breg/fixtures/action-plan-branches bregctl project action-test products/breg/fixtures/action-plan-branches --action create-entry --input products/breg/fixtures/action-plan-branches/examples/success.input.json --expect products/breg/fixtures/action-plan-branches/examples/success.expect.json bregctl project action-test products/breg/fixtures/action-plan-branches --action create-entry --input products/breg/fixtures/action-plan-branches/examples/refusal.input.json --expect products/breg/fixtures/action-plan-branches/examples/refusal.expect.json bregctl project action-test products/breg/fixtures/action-plan-branches --action invalid-copy --input products/breg/fixtures/action-plan-branches/examples/handler-failure.input.json --expect products/breg/fixtures/action-plan-branches/examples/handler-failure.expect.json +bregctl project action-test products/breg/fixtures/action-plan-branches --action create-dated-entry --input products/breg/fixtures/action-plan-branches/examples/date.input.json --expect products/breg/fixtures/action-plan-branches/examples/date.expect.json --evaluation-date 2026-01-31 ``` A matching expectation reports `assertionsPassed: true`. A changed effect or diff --git a/products/breg/fixtures/action-plan-branches/examples/date.expect.json b/products/breg/fixtures/action-plan-branches/examples/date.expect.json new file mode 100644 index 0000000000..500958665d --- /dev/null +++ b/products/breg/fixtures/action-plan-branches/examples/date.expect.json @@ -0,0 +1 @@ +{"effects":[{"id":"entry","set":{"label":"Dated entry","effective-on":"2026-01-31","expires-on":"2026-03-02","renewal-on":"2026-02-28"}}]} diff --git a/products/breg/fixtures/action-plan-branches/examples/date.input.json b/products/breg/fixtures/action-plan-branches/examples/date.input.json new file mode 100644 index 0000000000..84a5cc3c7a --- /dev/null +++ b/products/breg/fixtures/action-plan-branches/examples/date.input.json @@ -0,0 +1 @@ +{"label":"Dated entry"} diff --git a/products/breg/fixtures/action-plan-branches/plans/create-dated-entry.rhai b/products/breg/fixtures/action-plan-branches/plans/create-dated-entry.rhai new file mode 100644 index 0000000000..7d8248ab4e --- /dev/null +++ b/products/breg/fixtures/action-plan-branches/plans/create-dated-entry.rhai @@ -0,0 +1,12 @@ +fn plan(ctx) { + let effective_on = today(); + #{effects: [#{ + id: "entry", + set: #{ + label: ctx.inputs.label, + "effective-on": effective_on, + "expires-on": add_days(effective_on, 30), + "renewal-on": add_months(effective_on, 1), + }, + }]} +} diff --git a/products/breg/fixtures/action-plan-branches/registry.yaml b/products/breg/fixtures/action-plan-branches/registry.yaml index 46704a32c0..14287dcfcf 100644 --- a/products/breg/fixtures/action-plan-branches/registry.yaml +++ b/products/breg/fixtures/action-plan-branches/registry.yaml @@ -5,6 +5,7 @@ project: version: "1" defaultLanguage: en canonicalBaseIri: https://action-plans.example.test + timezone: UTC datasets: [{id: entries}] recordTypes: - id: entry @@ -13,9 +14,13 @@ recordTypes: mutationMode: create-only fields: - {id: label, type: string, required: true, maximumLength: 64, classification: internal} + - {id: effective-on, type: date, classification: internal} + - {id: expires-on, type: date, classification: internal} + - {id: renewal-on, type: date, classification: internal} functions: - {id: create-entry-plan, runtime: rhai, source: plans/create.rhai, entrypoint: plan, abi: registry.action-plan/v1} - {id: invalid-copy-plan, runtime: rhai, source: plans/invalid-copy.rhai, entrypoint: plan, abi: registry.action-plan/v1} + - {id: create-dated-entry-plan, runtime: rhai, source: plans/create-dated-entry.rhai, entrypoint: plan, abi: registry.action-plan/v1} actions: - id: create-entry execution: {mode: immediate} @@ -39,6 +44,16 @@ actions: inputs: [note] writes: - {id: entry, target: {recordType: entry}, operation: create, fields: [label]} + - id: create-dated-entry + execution: {mode: immediate} + inputs: + - {id: label, type: string, required: true, maximumLength: 64, classification: internal} + plan: + type: function + function: create-dated-entry-plan + inputs: [label] + writes: + - {id: entry, target: {recordType: entry}, operation: create, fields: [label, effective-on, expires-on, renewal-on]} accessProfiles: - id: author default: true @@ -48,3 +63,4 @@ accessProfiles: actions: - {action: create-entry, operations: [invoke], targets: [{recordType: entry, rowBoundaries: unrestricted}], results: [entry]} - {action: invalid-copy, operations: [invoke], targets: [{recordType: entry, rowBoundaries: unrestricted}], results: [entry]} + - {action: create-dated-entry, operations: [invoke], targets: [{recordType: entry, rowBoundaries: unrestricted}], results: [entry]} diff --git a/products/breg/generated/authoring/registry-module.schema.json b/products/breg/generated/authoring/registry-module.schema.json index dc99c488bc..da7d644037 100644 --- a/products/breg/generated/authoring/registry-module.schema.json +++ b/products/breg/generated/authoring/registry-module.schema.json @@ -808,6 +808,13 @@ "currentDate": { "$ref": "#/$defs/CurrentDatePredicateSource" }, + "currentDateOffsetDays": { + "description": "Whole calendar days added to the registry-local date before applying\n`currentDate`. Zero preserves the unshifted comparison.", + "format": "int32", + "maximum": 365000, + "minimum": -365000, + "type": "integer" + }, "equals": { "$ref": "#/$defs/ScalarLiteral" }, diff --git a/products/breg/generated/authoring/registry-project.schema.json b/products/breg/generated/authoring/registry-project.schema.json index 91ee846a45..f4c0119406 100644 --- a/products/breg/generated/authoring/registry-project.schema.json +++ b/products/breg/generated/authoring/registry-project.schema.json @@ -844,6 +844,13 @@ "currentDate": { "$ref": "#/$defs/CurrentDatePredicateSource" }, + "currentDateOffsetDays": { + "description": "Whole calendar days added to the registry-local date before applying\n`currentDate`. Zero preserves the unshifted comparison.", + "format": "int32", + "maximum": 365000, + "minimum": -365000, + "type": "integer" + }, "equals": { "$ref": "#/$defs/ScalarLiteral" }, diff --git a/products/breg/immediate-actions.md b/products/breg/immediate-actions.md index cd7ba50cec..e98bfd3000 100644 --- a/products/breg/immediate-actions.md +++ b/products/breg/immediate-actions.md @@ -62,12 +62,17 @@ An omitted optional Rhai input is the unit value `()`. Omit the corresponding field from an effect's `set` map. Copying `()` or `null` into a required, non-null field is a handler result failure; it does not clear the field. -The Action ABI supplies no trusted calendar date in `ctx`. When a command needs -a caller-selected business date, declare a typed input such as `asOfDate`; treat -it as caller data, not current server time. Plan logic runs only when that -Action is invoked and is not a Record field-validation boundary. If a rule must -not be bypassed, withhold the ordinary Create profile that could write the same -state without the Action. +The Rhai Action ABI provides three bounded date-string helpers: `today()` returns +the registry-local `YYYY-MM-DD` date captured once for the evaluation, +`add_days(date, days)` adds from -365,000 through 365,000 whole days, and +`add_months(date, months)` adds from -12,000 through 12,000 months while clamping +to the target month's last day. Invalid dates, an out-of-range result, or an +offset outside those bounds fails the handler without exposing the supplied +value. A Function that calls `today()` requires `project.timezone`; a +caller-selected business date remains a typed input such as `asOfDate` and is +caller data. Plan logic runs only when that Action is invoked and is not a +Record field-validation boundary. If a rule must not be bypassed, withhold the +ordinary Create profile that could write the same state without the Action. `bregctl check` performs one narrow source check for literal catalogue mistakes: it validates the string literal in a returned root map whose only key has the @@ -96,6 +101,21 @@ authority does not grant ordinary CRUD. For a Proposal Action, a `write` use under the Start grant describes an effect the proposal may apply after approval; it does not grant the Start caller a direct Record write. +A date guard may shift the registry-local date by a signed whole-day offset: + +```yaml +commitGuards: + targets: + - input: licence + field: valid-through + currentDate: on-or-after + currentDateOffsetDays: 30 +``` + +An absent or zero `currentDateOffsetDays` compares with today, preserving the +earlier `currentDate` behavior. The bounded offset is applied to the same local +date for advisory availability and commit or Proposal Apply re-evaluation. + An invocation commits record effects, revisions, history, audit, idempotency receipt, and outbox together. A refused effect leaves no partial record or event. The response discloses only granted result effects. Events describe committed @@ -207,11 +227,13 @@ advisory `guardReason` is neither authority nor a reservation. ```sh bregctl check ./registry bregctl --format json explain actions ./registry -bregctl project action-test ./registry --action --input /path/to/input.json +bregctl project action-test ./registry --action --input /path/to/input.json --evaluation-date 2026-01-31 ``` `action-test` runs a named plan function over bounded synthetic JSON using logical -input IDs, without a wire envelope. Add `--expect /path/to/expected.json` to check +input IDs, without a wire envelope. A handler that calls `today()` requires the +fixed `--evaluation-date`; the command never reads the workstation clock. Add +`--expect /path/to/expected.json` to check exact effects, a declared refusal, or a handler failure. The public [`action-plan-branches` fixture](fixtures/action-plan-branches/README.md) carries all three examples. A handler result error is asserted as diff --git a/products/breg/scripts/test_validate_product.py b/products/breg/scripts/test_validate_product.py index 182963b8f1..9004bf94ca 100644 --- a/products/breg/scripts/test_validate_product.py +++ b/products/breg/scripts/test_validate_product.py @@ -77,7 +77,7 @@ def test_security_range_is_closed_through_derived_sql_invariants(self) -> None: ) extension_rows = matrix["invariants"][24:] self.assertEqual( - [f"BREG-NEG-{index:02d}" for index in range(25, 203) if index not in {61, 62}], + [f"BREG-NEG-{index:02d}" for index in range(25, 204) if index not in {61, 62}], [invariant["negativeId"] for invariant in extension_rows], ) for invariant in extension_rows: diff --git a/products/breg/scripts/validate_product.py b/products/breg/scripts/validate_product.py index ad3a55c4c4..8780922c02 100644 --- a/products/breg/scripts/validate_product.py +++ b/products/breg/scripts/validate_product.py @@ -30,7 +30,7 @@ CONTRACT_STATES = {"enforced", "partial", "planned"} V1_REQUIREMENT_IDS = tuple(f"BREG-V1-{index:02d}" for index in range(1, 45)) ACCEPTANCE_JOURNEY_IDS = tuple(f"BREG-J{index:02d}" for index in range(1, 25)) -SECURITY_INVARIANT_IDS = tuple(f"BREG-SEC-{index:02d}" for index in range(1, 203) if index not in {61, 62}) +SECURITY_INVARIANT_IDS = tuple(f"BREG-SEC-{index:02d}" for index in range(1, 204) if index not in {61, 62}) ACCEPTANCE_FIXTURES = { "BREG-J01": ("asset-site-placement", "acceptance/asset-site-placement"), "BREG-J02": ("asset-site-placement", "acceptance/asset-site-placement"), diff --git a/products/casework/CHANGELOG.md b/products/casework/CHANGELOG.md index 5ae1713fe3..d01a406c6b 100644 --- a/products/casework/CHANGELOG.md +++ b/products/casework/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Let a review stage choose a subset of the policy's outcomes with + `stages[].outcomes`. The runtime enforces the stage's choices, and + `caseworkctl explain` lists them. A stage that omits the list offers every + policy outcome. + ## 0.46.0 - 2026-11-08 - Each access profile may carry an optional `label` and `description`. Neither is matched against a caller, a profile that writes neither reads as before, and rewording them leaves a policy's behavioral binding unchanged. Casework keeps both in the review policy description, which any caller who may read that description, requesters included, can see. diff --git a/products/casework/README.md b/products/casework/README.md index 78eadd40dd..14e5cc0ecb 100644 --- a/products/casework/README.md +++ b/products/casework/README.md @@ -14,6 +14,8 @@ guide](RUNTIME-CONFIG.md) and [current-source development guide](DEV-SOURCES.md) cover deployment bindings. The [task grant guide](TASK_GRANTS.md) covers bounded institutional agents. The generated [OpenAPI contract](generated/registry-casework.openapi.json) and [CLI report contract](contracts/cli/README.md) own exact wire shapes. +The [stage outcomes example](examples/stage-outcomes/README.md) keeps one review +history while offering different decisions at local and final review. ## First submitted review @@ -116,7 +118,7 @@ vocabulary belongs to the policy: `approve`, `reject`, `changes-requested`, or `answer`, each naming one of the policy's declared outcomes, with a reason or structured result when the selected outcome requires them. An approval names an outcome too; one without it is refused at `$.outcome`, and the -`Registry-Casework-Validation-Allowed` header lists the policy's approval +`Registry-Casework-Validation-Allowed` header lists the current stage's approval outcomes. Each outcome declares `publicReason: required`, `optional` or `none`: a public reason of at most 2000 UTF-8 bytes reaches the producer on the ReviewResult, while the private `reason` stays in the accountability @@ -137,6 +139,15 @@ and other approvers' public reasons, are kept for accountability and not released. Decisions and task holdings are revision-fenced; an old response cannot authorize a later state. +Each stage may set `outcomes` to the non-empty subset of policy outcome ids it +offers. Omitting the field offers every policy outcome, preserving existing +projects. `caseworkctl check` refuses an unknown outcome id and a stage with no +available path to advance or settle the review. `caseworkctl explain` always +reports the effective outcomes for each stage, including the all-policy default. +A `changes-requested` result ends that review version. A resubmission is a new +version that starts at the policy's first stage; it does not resume the earlier +review in place. + The same producer reads `GET /v1/review-requests/{requestId}/result` or its resumable result feed. A `ReviewResult` repeats the subject, policy, and submission digest and carries a terminal status such as `approved`, diff --git a/products/casework/SECURITY-MODEL.md b/products/casework/SECURITY-MODEL.md index 7b298eac55..57d6c6b913 100644 --- a/products/casework/SECURITY-MODEL.md +++ b/products/casework/SECURITY-MODEL.md @@ -24,8 +24,8 @@ Deployment behavior is in the [README](README.md), assertion and a single selected profile; profile scopes are never unioned (CASEWORK-SEC-01, CASEWORK-SEC-02). - **Private reason versus public reason.** Every decision names an outcome - declared by the pinned policy. The reviewer's private reason stays in the - accountability record. Only the outcome and an optional `publicReason` + declared by the pinned policy and offered at the current stage. The reviewer's + private reason stays in the accountability record. Only the outcome and an optional `publicReason` (at most 2000 UTF-8 bytes) reach the producer result, and the audit journal records only that a public reason was given (CASEWORK-SEC-31). - **Source concealment.** Lists, counts, ordering and exact request selection diff --git a/products/casework/contracts/cli/README.md b/products/casework/contracts/cli/README.md index 08ab7499f6..a9d685f135 100644 --- a/products/casework/contracts/cli/README.md +++ b/products/casework/contracts/cli/README.md @@ -39,6 +39,9 @@ Policy, context, stage, outcome, routing, clock, staffing and activation shapes come from their owning DTO schemas. Presentation labels remain inspectable in `explain`; policy bindings identify behavior. Package digests cover every captured authored byte, including labels and provider export files. +New explain reports list each stage's effective `outcomes` in policy catalogue +order. When the authored stage omits its subset, the list contains every +policy outcome. The schema also accepts earlier reports without this member. Each explained policy also reports `usedQueues`, `decidingProfiles`, `eligibleDecidingProfiles`, and `dependencyScope`. The report lists every project clock, calendar, and trusted producer at project level. The policy's diff --git a/products/casework/contracts/cli/report.schema.json b/products/casework/contracts/cli/report.schema.json index 59cb9cca98..ab9637319a 100644 --- a/products/casework/contracts/cli/report.schema.json +++ b/products/casework/contracts/cli/report.schema.json @@ -978,6 +978,12 @@ "negativeDecisions": { "$ref": "#/$defs/ReviewNegativeDecisionMode" }, + "outcomes": { + "items": { + "type": "string" + }, + "type": "array" + }, "queue": { "type": "string" }, diff --git a/products/casework/contracts/security-invariant-matrix.yaml b/products/casework/contracts/security-invariant-matrix.yaml index e25cb10f22..9bf46f8152 100644 --- a/products/casework/contracts/security-invariant-matrix.yaml +++ b/products/casework/contracts/security-invariant-matrix.yaml @@ -34,7 +34,7 @@ invariants: - {id: CASEWORK-SEC-28, invariants: [14, 17, 18], state: enforced, targetWave: beta, threat: "Request creation, absence changes or background retention bypass an unavailable audit destination.", enforcementPoint: "Existing audit admission before writes and acknowledged minimized response after commit.", refusal: "Refuse stopped writers and withhold success after response failure without duplicating committed effects.", negativeId: CASEWORK-NEG-28, negativeTest: {path: crates/registry-casework/tests/support/creation_audit_regressions.rs, name: producer_supersession_cannot_bypass_an_unavailable_audit_writer}} - {id: CASEWORK-SEC-29, invariants: [3, 18], state: enforced, targetWave: beta, threat: "Activation changes the authority needed by an accepted review or pins unrelated optional capabilities.", enforcementPoint: "Pinned semantic access profiles and only policy-reachable provider dependencies.", refusal: "Refuse authority changes while allowing unrelated drained capabilities to be removed.", negativeId: CASEWORK-NEG-29, negativeTest: {path: crates/registry-casework/tests/support/authority_closure_regressions.rs, name: assignment_roles_stay_pinned_and_activation_refuses_changed_deciding_authority}} - {id: CASEWORK-SEC-30, invariants: [2, 17, 18], state: enforced, targetWave: beta, threat: "A supervisor appoints absence cover through a shared team they do not supervise.", enforcementPoint: "Current Directory supervision over a team shared by the absent person and cover before creating the absence.", refusal: "Refuse cover outside the supervisor's current team authority.", negativeId: CASEWORK-NEG-30, negativeTest: {path: crates/registry-casework/tests/review_directory_postgres.rs, name: supervisor_cannot_record_cover_from_an_unsupervised_shared_team}} - - {id: CASEWORK-SEC-31, invariants: [9, 17], state: enforced, targetWave: beta, threat: "A reviewer's private reason or an advisory finding reaches the producer or audit journal, or a decision settles on an undeclared outcome or on one another approver in its stage did not choose.", enforcementPoint: "Declared outcome, per-outcome publicReason rule and stage outcome agreement checked in the decision transaction; result built only from the settling decision; audit allow-list carries publicReasonGiven.", refusal: "Refuse undeclared outcomes, an approval naming another outcome than its stage already chose, and missing, undeclared or oversized public reasons; never copy a private reason or advisory payload into the producer result or audit.", negativeId: CASEWORK-NEG-31, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: advisory_rejection_advances_to_final_approval_without_releasing_its_reason}} + - {id: CASEWORK-SEC-31, invariants: [9, 17], state: enforced, targetWave: beta, threat: "A reviewer's private reason or an advisory finding reaches the producer or audit journal, or a decision uses an outcome outside its stage or one another approver in its stage did not choose.", enforcementPoint: "The frozen policy catalogue, current stage's outcome subset, per-outcome publicReason rule and stage outcome agreement are checked in the decision transaction; result built only from the settling decision; audit allow-list carries publicReasonGiven.", refusal: "Refuse undeclared or unavailable outcomes, an approval naming another outcome than its stage already chose, and missing, undeclared or oversized public reasons; never copy a private reason or advisory payload into the producer result or audit.", negativeId: CASEWORK-NEG-31, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: advisory_rejection_advances_to_final_approval_without_releasing_its_reason}} - {id: CASEWORK-SEC-32, invariants: [5, 9, 17], state: enforced, targetWave: beta, threat: "A retained terminal Casework read leaks source context after the source conceals it, or loses the Casework-owned decision with that context; or, once the source stops calling the subject reviewable, it releases still-disclosed context to anyone other than the author of that task's decision on a request that has left review.", enforcementPoint: "Separate live source context disclosure from retained terminal task and history authorization. For a still-disclosed but non-reviewable subject, release context only once the request has left review, only to the recorded author of that task's decision, with authorship and current Casework membership, profile and retention checked before the provider read and re-checked after it; the source decides disclosure for the caller's own token and source profile in that read.", refusal: "Release only Casework-owned retained records under current Casework authority; keep concealed source context not found; keep non-reviewable context not found for other reviewers, for the authors of other tasks and for any author while the request is still reviewing; refuse expired or erased results as expired.", negativeId: CASEWORK-NEG-32, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: decided_current_source_context_is_retained_only_for_its_author}} - {id: CASEWORK-SEC-33, invariants: [4, 5, 9, 17], state: enforced, targetWave: beta, threat: "Task-grant evaluation borrows another source profile or accepts substituted request identity or unrequested source fields.", enforcementPoint: "Separately selected BReg task-context service profile, exact request echo and policy/subject/context binding, field-limited schema and current source disclosure.", refusal: "Refuse missing task profile, status or human-context fallback, malformed requests, mismatched responses and unrequested fields before deriving task authority; RoutingContext remains unsupported.", negativeId: CASEWORK-NEG-33, negativeTest: {path: crates/registry-casework-breg/tests/context_provider.rs, name: task_context_uses_only_its_distinct_profile_and_echoes_the_exact_request}} - {id: CASEWORK-SEC-34, invariants: [1, 3, 5, 9, 14, 17, 18], state: enforced, targetWave: beta, threat: "A gateway delegation impersonates a human, reads another person's assignment, reuses a cursor across reader policies, reaches a Casework mutation, promises current-source work without an exchange, leaks internal policy machinery, or leaks downstream failure values.", enforcementPoint: "Named delegated reader policy with exact client, actor subject, assertion issuer, audience, human assertion, required scope subset and lifetime; optional actor issuer must match the verified token issuer and nested actors are refused; delegated cursors are partitioned by reader policy; startup cross-check of policy-reachable current-source providers; dedicated read-only routes; one configured downstream request; one bulk post-source list recheck and before/after context holder and revision checks; model-visible context omits the internal policy closure while retaining the policy binding and review context; distinct OAuth clients separate unattended status authority from subject delegation; static gateway failure classes with a validated trace identifier; terminal minimized audit.", refusal: "Keep ordinary human authentication closed to delegated-reader act or scope, require source profiles and exchange only for policy-reachable current-source work, expose only assigned due list and assigned context without internal routing, clock or retention policy, expire cursors crossing reader policies, refuse changed holder/revision without probing or retrying, and withhold results until person plus gateway/client audit is accepted.", negativeId: CASEWORK-NEG-34, negativeTest: {path: crates/registry-casework/src/auth.rs, name: delegated_staff_read_negative_cases_have_value_free_stage_diagnostics}} diff --git a/products/casework/contracts/security-test-traceability.yaml b/products/casework/contracts/security-test-traceability.yaml index 316f4363f0..e7294505aa 100644 --- a/products/casework/contracts/security-test-traceability.yaml +++ b/products/casework/contracts/security-test-traceability.yaml @@ -32,7 +32,7 @@ entries: - {id: CASEWORK-SEC-28, tests: [{file: crates/registry-casework/tests/support/creation_audit_regressions.rs, name: producer_supersession_cannot_bypass_an_unavailable_audit_writer}, {file: crates/registry-casework/tests/support/authority_closure_regressions.rs, name: absence_reconciliation_requires_audit_and_recovers_without_phantom_changes}, {file: crates/registry-casework/tests/support/maintenance_audit_regressions.rs, name: stopped_writer_blocks_retention_and_available_writer_erases_minimally}]} - {id: CASEWORK-SEC-29, tests: [{file: crates/registry-casework/tests/support/authority_closure_regressions.rs, name: assignment_roles_stay_pinned_and_activation_refuses_changed_deciding_authority}, {file: crates/registry-casework/tests/support/authority_closure_regressions.rs, name: accepted_plain_policy_does_not_pin_another_policys_optional_provider_capabilities}]} - {id: CASEWORK-SEC-30, tests: [{file: crates/registry-casework/tests/review_directory_postgres.rs, name: supervisor_cannot_record_cover_from_an_unsupervised_shared_team}]} - - {id: CASEWORK-SEC-31, tests: [{file: crates/registry-casework/tests/review_postgres.rs, name: advisory_rejection_advances_to_final_approval_without_releasing_its_reason}, {file: crates/registry-casework/tests/review_postgres.rs, name: rejected_cancelled_and_superseded_results_commit_atomically}, {file: crates/registry-casework/tests/review_postgres.rs, name: review_decisions_are_audited_after_the_decision_commits}, {file: crates/registry-casework/tests/review_acceptance_postgres.rs, name: approvals_in_one_stage_must_agree_on_the_outcome_they_release}]} + - {id: CASEWORK-SEC-31, tests: [{file: crates/registry-casework/tests/review_postgres.rs, name: advisory_rejection_advances_to_final_approval_without_releasing_its_reason}, {file: crates/registry-casework/tests/review_postgres.rs, name: rejected_cancelled_and_superseded_results_commit_atomically}, {file: crates/registry-casework/tests/review_postgres.rs, name: review_decisions_are_audited_after_the_decision_commits}, {file: crates/registry-casework/tests/review_acceptance_postgres.rs, name: approvals_in_one_stage_must_agree_on_the_outcome_they_release}, {file: crates/registry-casework/tests/review_http.rs, name: stage_outcomes_allow_local_changes_request_but_refuse_it_at_final_over_http}]} - {id: CASEWORK-SEC-32, tests: [{file: crates/registry-casework/tests/review_postgres.rs, name: decided_review_state_stays_readable_after_the_source_conceals_context}, {file: crates/registry-casework/tests/review_postgres.rs, name: decided_current_source_context_is_retained_only_for_its_author}, {file: crates/registry-casework/tests/review_postgres.rs, name: earlier_stage_author_gets_no_context_while_the_request_is_still_reviewing}, {file: crates/registry-casework/tests/review_postgres.rs, name: retained_context_authorship_is_scoped_to_the_decided_task_and_issuer}, {file: crates/registry-casework/tests/breg_review_journey.rs, name: native_proposal_requires_pinned_casework_approval_before_breg_apply}]} - {id: CASEWORK-SEC-33, tests: [{file: crates/registry-casework-breg/tests/context_provider.rs, name: task_context_uses_only_its_distinct_profile_and_echoes_the_exact_request}, {file: crates/registry-casework-breg/tests/context_provider.rs, name: task_context_has_no_status_profile_fallback}, {file: crates/registry-casework-breg/tests/context_provider.rs, name: task_context_refuses_invalid_and_unknown_fields_before_credentials_or_io}, {file: crates/registry-casework-breg/src/lib.rs, name: task_projection_keeps_only_definitions_reachable_from_selected_properties}]} - {id: CASEWORK-SEC-34, tests: [{file: crates/registry-casework/src/auth.rs, name: delegated_staff_read_is_separate_from_human_authentication}, {file: crates/registry-casework/src/auth.rs, name: delegated_staff_read_refuses_changed_actor_scope_lifetime_and_source_profile}, {file: crates/registry-casework/src/auth.rs, name: delegated_staff_reader_accepts_rfc_actor_and_rejects_actor_substitution}, {file: crates/registry-casework/src/auth.rs, name: delegated_staff_read_negative_cases_have_value_free_stage_diagnostics}, {file: crates/registry-casework/src/auth/delegated.rs, name: delegated_caller_refusals_are_not_audited_as_runtime_failures}, {file: crates/registry-casework/src/auth/delegated.rs, name: delegated_recheck_conceals_authority_changes_but_preserves_store_failures}, {file: crates/registry-casework/src/config.rs, name: delegated_staff_reader_allows_client_rotation_for_one_staff_profile}, {file: crates/registry-casework/src/config.rs, name: delegated_reader_source_profiles_require_exchange_on_reachable_configured_provider}, {file: crates/registry-casework/src/config.rs, name: submitted_only_delegated_reader_may_omit_source_profiles}, {file: crates/registry-casework/tests/review_http.rs, name: delegated_reader_credential_cannot_enter_producer_create_or_cancel_routes}, {file: crates/registry-casework/tests/review_http.rs, name: delegated_review_task_list_withholds_results_when_audit_begin_or_terminal_fails}, {file: crates/registry-casework/tests/review_http.rs, name: delegated_review_task_context_withholds_results_when_audit_begin_or_terminal_fails}, {file: crates/registry-casework/tests/review_http.rs, name: delegated_submitted_context_ignores_admitted_source_profile_and_audits_one_success}, {file: crates/registry-casework/tests/review_http.rs, name: delegated_cursor_is_partitioned_by_reader_policy_during_client_rotation}, {file: crates/registry-casework/tests/review_http.rs, name: delegated_reads_withhold_rows_and_context_when_assignment_changes_during_source_io}, {file: crates/registry-casework-client/tests/staffing_boundary.rs, name: delegated_staff_read_uses_only_its_dedicated_route_and_policy_header}, {file: crates/registry-casework-breg/src/lib.rs, name: delegated_exchange_uses_exact_source_authority}, {file: crates/registry-mcp/src/casework.rs, name: context_uses_the_configured_source_profile_in_one_request}, {file: crates/registry-mcp/src/casework.rs, name: model_visible_context_omits_internal_policy_closure}, {file: crates/registry-mcp/src/casework.rs, name: casework_failure_log_keeps_only_static_class_status_and_safe_trace}, {file: crates/registry-mcp/tests/provider_contract.rs, name: casework_failures_have_static_actionable_classes_and_preserve_safe_trace}, {file: crates/registry-mcp/tests/native_casework.rs, name: native_staff_gateway_preserves_delegated_read_authority}]} diff --git a/products/casework/examples/stage-outcomes/README.md b/products/casework/examples/stage-outcomes/README.md new file mode 100644 index 0000000000..d2d0c1eb30 --- /dev/null +++ b/products/casework/examples/stage-outcomes/README.md @@ -0,0 +1,18 @@ +# Stage-specific review outcomes + +This submitted-context policy keeps one review history across two levels. The +local review can approve, reject, or request changes. The final review can only +approve or reject. + +Run the offline authoring checks from the repository root: + +```sh +caseworkctl check products/casework/examples/stage-outcomes +caseworkctl explain products/casework/examples/stage-outcomes +caseworkctl test products/casework/examples/stage-outcomes +``` + +`explain` reports the effective outcomes offered by each stage. `test` checks +the synthetic submitted context and the local stage's queue and outcome +vocabulary. These offline commands do not exercise a deployed review or prove +runtime decision transitions. diff --git a/products/casework/examples/stage-outcomes/casework.yaml b/products/casework/examples/stage-outcomes/casework.yaml new file mode 100644 index 0000000000..afb5050651 --- /dev/null +++ b/products/casework/examples/stage-outcomes/casework.yaml @@ -0,0 +1,83 @@ +apiVersion: id.registrystack.org/formats/casework/project/v1alpha1 +kind: CaseworkProject +project: + id: two-level-grant-review + version: "1" +accessProfiles: + local-reviewer: + principalClaim: sub + requiredScopes: [casework:local-review] + actsAs: staff + final-reviewer: + principalClaim: sub + requiredScopes: [casework:final-review] + actsAs: staff + supervisor: + principalClaim: sub + requiredScopes: [casework:supervise] + actsAs: supervisor + administrator: + principalClaim: sub + requiredScopes: [casework:admin] + actsAs: administrator + requester: + principalClaim: sub + requiredScopes: [casework:request] + actsAs: requester +queues: + local-review: + label: Local grant reviews + final-review: + label: Final grant reviews +reviewPolicies: + grant-review: + version: "1" + purpose: approval + context: {mode: submitted} + stages: + - id: local-review + label: Local review + queue: local-review + decidingProfiles: [local-reviewer] + requiredApprovals: 1 + outcomes: [approved, changes-requested, rejected] + - id: final-review + label: Final review + queue: final-review + decidingProfiles: [final-reviewer] + requiredApprovals: 1 + outcomes: [approved, rejected] + excludePreviousStageReviewers: true + retention: + terminalRetentionDays: 30 + accountabilityRetentionDays: 365 + displaySchema: + type: object + additionalProperties: false + required: [summary] + properties: + summary: {type: string, maxLength: 160} + outcomes: + approved: + label: Approve + settlement: approved + reasonRequired: false + publicReason: optional + changes-requested: + label: Request changes + settlement: changes-requested + reasonRequired: true + publicReason: optional + rejected: + label: Reject + settlement: rejected + reasonRequired: true + publicReason: optional +trustedProducers: + grant-service: + profile: requester + issuer: https://identity.example.test + subject: grant-service.example.test + sourceNamespaces: [grant-register] + reviewPolicies: [grant-review] + retention: {receiptRetentionDays: 30} diff --git a/products/casework/examples/stage-outcomes/fixtures/grant-review.yaml b/products/casework/examples/stage-outcomes/fixtures/grant-review.yaml new file mode 100644 index 0000000000..e6fb64181e --- /dev/null +++ b/products/casework/examples/stage-outcomes/fixtures/grant-review.yaml @@ -0,0 +1,10 @@ +apiVersion: id.registrystack.org/formats/casework/fixture/v1alpha1 +kind: CaseworkFixture +id: two-level-grant-review-offline +review: + policy: grant-review + display: + summary: Review the synthetic grant application +expect: + queue: local-review + outcomes: [approved, changes-requested, rejected] diff --git a/products/casework/generated/project/project.schema.json b/products/casework/generated/project/project.schema.json index a301f6f725..ff0e404bc2 100644 --- a/products/casework/generated/project/project.schema.json +++ b/products/casework/generated/project/project.schema.json @@ -414,6 +414,15 @@ "default": "settle", "description": "Controls whether a rejection or change request settles the whole review.\n`settle` is the default; `advise` records and counts the decision toward\nthis non-final stage without settling the review." }, + "outcomes": { + "description": "Outcome identifiers offered by this stage. Omission offers the whole\npolicy catalogue.", + "items": { + "$ref": "#/$defs/LocalId" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, "queue": { "type": "string" }, diff --git a/products/casework/generated/registry-casework.openapi.json b/products/casework/generated/registry-casework.openapi.json index 6f77c509b3..451eccc314 100644 --- a/products/casework/generated/registry-casework.openapi.json +++ b/products/casework/generated/registry-casework.openapi.json @@ -4053,6 +4053,18 @@ "default": "settle", "description": "Controls whether a rejection or change request settles the whole review.\n`settle` is the default; `advise` records and counts the decision toward\nthis non-final stage without settling the review." }, + "outcomes": { + "description": "Outcome identifiers offered by this stage. Omission offers the whole\npolicy catalogue.", + "items": { + "$ref": "#/components/schemas/LocalId" + }, + "minItems": 1, + "type": [ + "array", + "null" + ], + "uniqueItems": true + }, "queue": { "type": "string" }, @@ -18633,7 +18645,7 @@ "description": "Problem: request.invalid, source-profile.not-applicable, source-profile.required", "headers": { "Registry-Casework-Validation-Allowed": { - "description": "Comma-separated outcome identifiers the review policy declares for the requested settlement; present only with the outcome-not-declared reason.", + "description": "Comma-separated outcome identifiers available at the current stage, or the single outcome chosen by an earlier approval in that stage. Present when outcome-not-declared or approval-outcome-differs can name an allowed choice.", "schema": { "maxLength": 1039, "pattern": "^[a-z][a-z0-9_-]{0,63}(,[a-z][a-z0-9_-]{0,63}){0,15}$", diff --git a/products/casework/scripts/generate_openapi.py b/products/casework/scripts/generate_openapi.py index 36d7e6de7e..158d1939a2 100644 --- a/products/casework/scripts/generate_openapi.py +++ b/products/casework/scripts/generate_openapi.py @@ -250,7 +250,7 @@ def document(contract: dict, wire: dict) -> dict: prefix="Registry-Review" if endpoint.review else "Registry-Casework" headers[prefix+"-Validation-Path"]={"description":"Bounded value-free JSON path; present together with the validation reason.","schema":{"type":"string","maxLength":256}} headers[prefix+"-Validation-Reason"]={"description":"Closed value-free validation category; present together with the validation path.","schema":ref("ReviewValidationReason")} - if endpoint.body=="ReviewTaskDecisionRequest":headers["Registry-Casework-Validation-Allowed"]={"description":"Comma-separated outcome identifiers the review policy declares for the requested settlement; present only with the outcome-not-declared reason.","schema":{"type":"string","maxLength":1039,"pattern":"^[a-z][a-z0-9_-]{0,63}(,[a-z][a-z0-9_-]{0,63}){0,15}$"}} + if endpoint.body=="ReviewTaskDecisionRequest":headers["Registry-Casework-Validation-Allowed"]={"description":"Comma-separated outcome identifiers available at the current stage, or the single outcome chosen by an earlier approval in that stage. Present when outcome-not-declared or approval-outcome-differs can name an allowed choice.","schema":{"type":"string","maxLength":1039,"pattern":"^[a-z][a-z0-9_-]{0,63}(,[a-z][a-z0-9_-]{0,63}){0,15}$"}} if endpoint.body and "422" in responses: prefix="Registry-Review" if endpoint.review else "Registry-Casework" headers=responses["422"].setdefault("headers",{}) diff --git a/products/coordinator/CHANGELOG.md b/products/coordinator/CHANGELOG.md index 3c4930f392..180b47ab8c 100644 --- a/products/coordinator/CHANGELOG.md +++ b/products/coordinator/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +- BREAKING: protected state schema version 6 adds the durable calendar cursor + and the optional service-deadline instant on each Run. Version 5 namespaces + are refused; settle their Runs with the matching binary, then apply this + version to a fresh namespace and database identity. +- Add reviewed monthly calendar starts using `MIN HOUR 1 * *` or + `MIN HOUR L * *` in a named IANA timezone. Admission supplies only + `scheduledAt`, catches up with the latest due occurrence, records the exact + skipped count, and uses the ordinary fenced Run admission transaction. +- Add optional `onFailure` to read and evaluation call steps. It routes only + after the ordinary retry limit is exhausted by a definite availability + category, records that bounded category on the run, and provides no call + output on the failure edge. Mutations, authorization and configuration + refusals, preparation failures, invalid protocol answers and uncertain + outcomes remain operational failures. + ## 0.46.0 - 2026-11-08 - BREAKING: This release does not read workflow snapshots sealed by 0.45.0 or earlier: a snapshot whose project presentation, function or operation holds `title` is refused with `definition.snapshot`. A step `title` in the free-form `presentation.steps` map still restores and is shown as written. Finish open runs on the earlier release before upgrading. diff --git a/products/coordinator/DEPLOYMENT.md b/products/coordinator/DEPLOYMENT.md index 659f6ba514..9599530221 100644 --- a/products/coordinator/DEPLOYMENT.md +++ b/products/coordinator/DEPLOYMENT.md @@ -670,6 +670,22 @@ must retain each live run's original binding. New admissions repeat that check inside their insertion transaction; worker polls keep their per-job fences without rescanning history every poll. +The serving process checks its one reviewed calendar trigger per Workflow at +most once per second. PostgreSQL transaction time, not the process clock, +decides which occurrence is due. A trigger's first check seeds its cursor at +that database time. A changed Workflow revision also advances the cursor +monotonically to its first observed database time and admits nothing on that +check. Later checks hold the ordinary admission lock and cursor row, admit only +the latest due occurrence, add older due occurrences to the skipped count, +and advance the cursor in the Run admission transaction. An idle check +performs no cursor update, so a backward database clock cannot rewind it. Lock +acquisition has a 50 millisecond database timeout and the full calendar sweep +has a 250 millisecond process deadline. A competing replica observes the same +row; a changed activation, restore hold or admission hold refuses the calendar +check before it advances. A refusal or bounded timeout is logged with its +bounded code and does not stop the ordinary worker from progressing Runs +already admitted. Recovery-only mode performs no calendar checks. + A live run whose retained definition this version does not restore does not stop startup, readiness, admissions or apply. The service holds that run alone: it writes the hold, with the restore finding code, to the durable audit before @@ -726,7 +742,7 @@ admissions while old runs keep their encrypted snapshots and command identities. Adding an unrelated logical connection does not change a workflow binding digest. Reusing an idempotency key returns the original run rather than creating new work. -This source version creates protected state schema version 5. Earlier protected +This source version creates protected state schema version 6. Earlier protected state layouts are refused: apply reports `coordinator.command.schema-version`, and runtime state access, including `deployment-status`, fails closed as `coordinator.command.store-unavailable`. Apply does not adopt or rewrite them. Settle retained work with its matching diff --git a/products/coordinator/PRODUCT-CONTRACT.md b/products/coordinator/PRODUCT-CONTRACT.md index 607953632a..d36f42d655 100644 --- a/products/coordinator/PRODUCT-CONTRACT.md +++ b/products/coordinator/PRODUCT-CONTRACT.md @@ -45,6 +45,22 @@ The compiler resolves schema references and proves data availability on every ro commands are validated again before preparation, saving, or dispatch. Invalid commands report a bounded `failurePath` without the submitted value. +A call whose catalog effect is `read` or `evaluation` may declare `onFailure`. +The edge is taken only after the normal retry limit is exhausted by one of the +host's closed, definite availability categories: transport unavailable, rate +limited, attempt timeout, external unavailable, external rate limited, or +decision unavailable. The category is recorded as the run's bounded failure +code while the routed successor is current, and inspection retains it on the +completed call step after later progress. The call contributes no step output +on this edge, so a mapping cannot turn an operational diagnostic into a +business result. Authentication, authorization, credential configuration, +preparation, binding, command or response validation, and protocol refusals +remain operational failures. An uncertain outcome never routes, including a +lost evaluation reply after possible model dispatch. Mutations cannot declare +`onFailure`. The compiler checks those rules, the target, reachability and +output dominance in the frozen graph. Omitting the edge preserves the existing +failure behavior. + A `fields` mapping builds an object member by member. Each member is a reference (`input`, `step` or `literal`) or itself a `fields` mapping, so a Workflow builds a structured operation input, such as a Proposal's Action input or a read-in-force `subject`, without a Function. Nesting reaches a @@ -444,6 +460,17 @@ blocked source state stays held for investigation and recovery. None is evidence business decision. A Coordinator timeout means the workflow stopped waiting; source-owned work can continue. The timeout and run deadline do not reset during recovery. +An optional service deadline counts elapsed time from durable run admission. +It requires `workflow.serviceDeadlineSeconds` below `deadlineSeconds` and an +`onDeadline` path disjoint from the ordinary graph. Coordinator caps wait +wake-ups at the service deadline. A completion wait uses its existing read +floor and budget for a final observation. A settled result takes its ordinary +route. A still-pending wait at the service deadline takes `onDeadline` once, +including after restart. The transition consumes the service deadline in the +same transaction that enqueues its successor. A shorter wait keeps its own +timeout route. The deadline route carries no output from the interrupted wait. +Calls already in progress keep their dispatch and recovery rules. + Definite retryable effect responses can resend the durable original command under bounded retry and deadline rules. An ambiguous mutation stays uncertain. Messaging submission and Scheduling appointment creation or hold confirmation support original-key receipt reads. Scheduling hold @@ -462,6 +489,31 @@ Coordinator is not a Review provider. A project that declares `workflow.review` the runtime serves no `/v1/review-requests` route. When a Workflow needs human review it submits its own request to Casework under its independently registered producer identity. +An authored `workflow.calendarTriggers` entry names one stable trigger, a +five-field cron restricted to `MIN HOUR 1|L * *`, and a named IANA timezone. +It admits an ordinary Run with exactly one input member, `scheduledAt`, holding +the resolved UTC instant. A calculated local time inside a daylight-saving gap +moves to the first real instant after the gap; a fold chooses its earlier +instant. The shared platform calendar evaluator owns that named-zone rule; +Coordinator owns the reviewed cron subset and admission policy. + +Each workflow and trigger has one PostgreSQL cursor. The serving process seeds +a new cursor at its first observation, so it does not manufacture historical +Runs. A later sweep advances from the durable cursor through the transaction's +database time. When several occurrences became due, it admits the latest only +and adds every earlier due occurrence to the cursor's skipped count. The +cursor update, Run, first job and start identity commit in one ordinary +admission transaction. The admission lock, cursor row lock, active package +digest, key custody, restore hold and admission hold fence restart and +competing replicas. The start identity binds workflow, trigger and exact +occurrence instant, so replay cannot create another Run for that occurrence. +When the checked Workflow revision changes, its first check advances the +cursor monotonically to the new revision's observation time and admits +nothing, so an earlier instant is not reinterpreted under a changed schedule. +Calendar lock acquisition and the full serving-process sweep are short and +bounded, so a contended admission fence cannot starve work already admitted. +Calendar admission adds no HTTP API and grants no downstream product authority. + An authored `workflow.eventTriggers` entry accepts a logical source, exact event type and data-schema identity, a schema for the full event and an ordinary typed input mapping. `POST /v1/events/{workflow}/{trigger}` accepts the shared platform HookEnvelope and v1 delivery @@ -515,6 +567,8 @@ receiver over HTTP. | Guarantee | Owning test | |---|---| +| A reviewed monthly calendar start resolves one named-zone instant, catches up with the latest occurrence only, and supplies only `scheduledAt`. | `calendar_trigger::tests::first_and_last_day_schedules_resolve_in_the_named_zone`, `catch_up_selects_only_the_latest_due_instant_and_counts_every_skipped_one`, and `calendar_trigger_authoring::a_calendar_trigger_is_checked_explained_and_restored` | +| First observation creates no historical Run; restart, a competing replica, cursor replay, restore hold and activation replacement cannot create a second Run for one workflow and occurrence. | `calendar_trigger_authoring::calendar_admission_is_latest_only_atomic_and_fenced` | | A signature binds the destination, source, event contract and canonical body. | `event_trigger_tests::substituted_signature_destination_source_and_contract_are_refused` | | Delivery attempt time is fresh; an old event commit time alone does not prevent recovery. | `event_trigger_tests::stale_delivery_noncanonical_body_and_header_body_disagreement_are_refused` and `exact_event_identity_survives_retry_replay_and_old_commit_time` | | Concurrent delivery, restart, package activation and local source alias renaming recover one original Run; changed event bytes conflict. | `event_trigger_tests::postgres::event_admission_is_atomic_deduplicated_and_recovers_after_restart_and_activation` and `source_alias_rename_recovers_one_original_run_after_restart` | @@ -659,7 +713,7 @@ Unknown product mutations do not qualify for this exception. ## Compatibility and verification -Protected state schema version 5 directly replaces earlier layouts. Apply and runtime access +Protected state schema version 6 directly replaces earlier layouts. Apply and runtime access refuse incompatible namespaces. Settle old work with its matching binary and preserve original packages, keys, and audit until retention allows erasure, then provision a fresh namespace and database identity. There are no legacy authoring aliases or automatic state conversions. diff --git a/products/coordinator/README.md b/products/coordinator/README.md index 8f2fe77aa7..aa722091ca 100644 --- a/products/coordinator/README.md +++ b/products/coordinator/README.md @@ -8,7 +8,7 @@ retain authority and ownership of every effect. The [product contract](PRODUCT-CONTRACT.md) defines the boundary; the [deployment runbook](DEPLOYMENT.md) covers installation, custody, activation, and recovery. Coordinator is pre-1.0. Use one source version's binaries, packages, configuration, and state -layout together. The current protected state schema is version 5, with no in-place migration +layout together. The current protected state schema is version 6, with no in-place migration from earlier layouts. Build the Coordinator service, operator CLI, client, and client bindings from source using @@ -86,6 +86,24 @@ workflow: operationOutput: read-application ``` +`onFailure` is optional on a `call` whose Operation effect is `read` or +`evaluation`. It names a step taken only when the ordinary retry policy is +exhausted by a definite, bounded availability category. Run status records +that category while the routed successor is current, and inspection retains it +on the completed call step. The failure edge receives no output from the call. A +mutation cannot declare this edge. Authentication, authorization, +configuration, preparation, binding and protocol refusals still stop the run, +as does every uncertain outcome, including a model request whose reply was +lost after dispatch. `coordinatorctl check` refuses an unknown target and any +use on an operation with a mutation effect. + +Set `workflow.serviceDeadlineSeconds` and `workflow.onDeadline` together to +route a run that is still waiting after that many seconds from admission. +Keep the service deadline shorter than `workflow.deadlineSeconds`, the hard +run cutoff. Give the deadline route its own steps, for example a notice and a +finish. Its first step can read workflow input. `coordinatorctl explain` shows +both clocks and the route. + Mappings select the workflow input, an available earlier step output, or a literal. `path` is an RFC 6901 JSON Pointer. `type: fields` builds an object. `type: function` invokes a named Function with an `inputs` object whose keys exactly match its declared inputs. Each Rhai entrypoint takes @@ -167,6 +185,42 @@ text by default or a `CoordinatorCtlReport` with `--format json`. Exit codes are - [External job](examples/external-job/README.md) uses a bounded completion poll and pure interpreter for one previously selected external job. +## Calendar starts + +To start one Run at a reviewed institutional month boundary, declare a +`workflow.calendarTriggers` entry. The first slice accepts one minute, one +hour, and either the first or last local day of every month: + +```yaml +workflow: + calendarTriggers: + month-end: + cron: 5 8 L * * + timezone: Asia/Bangkok + input: + type: object + additionalProperties: false + required: [scheduledAt] + properties: + scheduledAt: {type: string, format: date-time} +``` + +The Run input is exactly `{scheduledAt}` with the resolved UTC instant. The +named IANA timezone controls the local month, day and time. A local time inside +a daylight-saving gap moves to the first real instant after the gap, and a +fold takes its earlier instant, using the shared Registry Platform calendar +rule. + +PostgreSQL holds one durable cursor per workflow and trigger. After downtime, +Coordinator admits only the latest due occurrence and records the number of +older occurrences skipped. Replicas serialize on the ordinary admission lock +and cursor row. The same workflow and occurrence always derive the same start +identity, so restart, replay, package replacement and a competing replica do +not create a second Run. A new trigger starts from the first time the serving +deployment observes it; it does not replay earlier institutional history. A +changed Workflow revision also moves the cursor monotonically to that +revision's first observation before evaluating its reviewed schedule. + ## Waits and polling To react to product events, declare `workflow.eventTriggers` and bind each logical source in diff --git a/products/coordinator/SECURITY-MODEL.md b/products/coordinator/SECURITY-MODEL.md index 0c9691e705..82d1613405 100644 --- a/products/coordinator/SECURITY-MODEL.md +++ b/products/coordinator/SECURITY-MODEL.md @@ -584,6 +584,73 @@ finding code. `cancelling_a_wait_whose_definition_does_not_restore_never_routes_its_cleanup`. `src/deployment.rs`: `a_pinned_package_that_does_not_restore_gives_operator_context`. +### COORDINATOR-INV-13: a failure route cannot hide authority or an uncertain effect + +**Threat.** A workflow fallback intended for an unavailable read could hide an +authorization refusal or continue after a mutation or model evaluation may +have completed. It could also treat a provider error body as workflow data. + +**Enforcement.** `Definition::check` accepts `onFailure` only on catalog +operations classified as reads or evaluations and includes it in the frozen +acyclic graph. The graph propagates no call output on that edge. The worker +takes it only on the final ordinary attempt, with no prior uncertainty, for +the closed availability categories in `availability_failure`. Preparation +never enables the route. The accepted transition carries no output and keeps +only the bounded category as the run failure code while its successor is +current; inspection retains it on the completed call job. Restart uses the +retained attempt count, definition and edge. + +**Refused.** Mutations; unknown targets; credential, configuration, +authorization, preparation, binding, command, response and protocol failures; +unknown retryable text; and every uncertain outcome. A lost or timed-out model +reply stays `evaluation-uncertain` and cannot route. + +**Tests.** `crates/registry-coordinator/tests/authored_project.rs`: +`reads_and_evaluations_accept_on_failure_but_mutations_refuse_it` and +`a_failure_route_has_no_call_output_and_names_a_declared_step`. +`src/worker.rs`: +`only_exhausted_explicit_availability_can_take_a_failure_edge`. +`tests/postgres_state.rs`: +`an_exhausted_read_routes_after_restart_with_a_bounded_category_and_no_output` +and `an_authorization_refusal_never_takes_a_read_failure_route` (PostgreSQL). + +### COORDINATOR-INV-14: a calendar occurrence admits at most one Run + +**Threat.** A restart, competing replica, package replacement or restored +database could evaluate the same institutional time twice and create two Runs. +After downtime, replaying every missed month could also release an unreviewed +burst of downstream work. A restore hold that stopped ordinary starts but not +calendar starts would reopen ingress through the scheduler. + +**Enforcement.** `CalendarSchedule` accepts only the reviewed monthly first or +last day subset and delegates named-zone gap and fold resolution to +`registry-platform-calendar`. `Store::admit_calendar_due` takes the ordinary +`admission_lock`, verifies custody and the active workflow digest, checks both +admission and restore holds, and locks the workflow and trigger cursor. It +evaluates through PostgreSQL transaction time, selects the latest due +occurrence, and counts every older due occurrence as skipped. The Run, first +job and advanced cursor commit in the same transaction. The ordinary start +identity binds the workflow, trigger and exact occurrence instant, so a +rewound cursor recovers the existing Run instead of inserting another one. A +new cursor starts at first observation and creates no historical Run. A +changed Workflow revision advances the cursor monotonically to its own first +observation before evaluation. Calendar admission uses a short lock timeout +and the serving process bounds the whole sweep, so lock contention yields to +the ordinary worker instead of starving admitted Runs. + +**Refused.** Unsupported cron fields, another day of month, a non-IANA +timezone, a Workflow input that does not accept the scheduledAt-only object, +changed activation or key custody, any admission or restore hold, and a +contended admission fence that exceeds the calendar lock bound. + +**Tests.** `src/calendar_trigger.rs`: +`only_the_reviewed_monthly_cron_subset_is_accepted`, +`a_fold_produces_one_earlier_instant_and_an_exclusive_successor`, and +`catch_up_selects_only_the_latest_due_instant_and_counts_every_skipped_one`. +`tests/calendar_trigger_authoring.rs`: +`a_calendar_trigger_is_checked_explained_and_restored` and +`calendar_admission_is_latest_only_atomic_and_fenced` (PostgreSQL). + ## 3. Operator obligations 1. Provide idempotency for external commands: the provider must deduplicate on diff --git a/products/coordinator/generated/project/project.schema.json b/products/coordinator/generated/project/project.schema.json index 86a87fba3c..0ccb04ae4f 100644 --- a/products/coordinator/generated/project/project.schema.json +++ b/products/coordinator/generated/project/project.schema.json @@ -18,6 +18,32 @@ ], "type": "object" }, + "AuthoredCalendarTrigger": { + "additionalProperties": false, + "properties": { + "cron": { + "description": "Five-field cron restricted to `MIN HOUR 1|L * *` in Version 1.", + "type": "string" + }, + "description": { + "$ref": "#/$defs/DisplayText", + "description": "Optional explanation for readers; it has no execution or authorization effect." + }, + "label": { + "$ref": "#/$defs/DisplayText", + "description": "Optional human label; it changes neither stable identity nor behavior revision." + }, + "timezone": { + "description": "Named IANA timezone used to interpret the local month, day and time.", + "type": "string" + } + }, + "required": [ + "cron", + "timezone" + ], + "type": "object" + }, "AuthoredCompletion": { "oneOf": [ { @@ -508,6 +534,10 @@ "$ref": "#/$defs/LocalId", "description": "A separate Step taken only for a validated package-declared business refusal.\nOmit it to fail the run with `product-refused` on a declared refusal too." }, + "onFailure": { + "$ref": "#/$defs/LocalId", + "description": "A separate Step taken only after a read or evaluation exhausts\nretries for a definite availability failure. It receives no Step\noutput; the bounded category remains run status." + }, "operation": { "$ref": "#/$defs/LocalId", "description": "One local Operation alias declared under operations." @@ -603,6 +633,18 @@ "AuthoredWorkflow": { "additionalProperties": false, "properties": { + "calendarTriggers": { + "additionalProperties": { + "$ref": "#/$defs/AuthoredCalendarTrigger" + }, + "description": "Optional reviewed monthly starts keyed by stable trigger ID.", + "maxProperties": 1, + "minProperties": 0, + "propertyNames": { + "$ref": "#/$defs/LocalId" + }, + "type": "object" + }, "deadlineSeconds": { "description": "Maximum seconds from Run admission to expiry, from 1 to 31536000.", "format": "uint32", @@ -626,6 +668,10 @@ "$ref": "#/$defs/Schema", "description": "The typed Workflow start input, or a mapping supplying the called Operation input." }, + "onDeadline": { + "$ref": "#/$defs/LocalId", + "description": "Entry to a separate path for a Run still waiting at its service deadline." + }, "outcomes": { "additionalProperties": { "$ref": "#/$defs/Schema" @@ -638,6 +684,13 @@ }, "type": "object" }, + "serviceDeadlineSeconds": { + "description": "Elapsed seconds from Run admission before a waiting Run takes onDeadline.", + "format": "uint32", + "maximum": 31535999, + "minimum": 1, + "type": "integer" + }, "start": { "$ref": "#/$defs/LocalId", "description": "The stable Step ID every Run begins at." diff --git a/products/coordinator/generated/scenarios/scenarios.schema.json b/products/coordinator/generated/scenarios/scenarios.schema.json index 6d936ab1e7..2aa707f75c 100644 --- a/products/coordinator/generated/scenarios/scenarios.schema.json +++ b/products/coordinator/generated/scenarios/scenarios.schema.json @@ -210,11 +210,11 @@ "items": { "$ref": "#/$defs/AuthoredReply" }, - "maxItems": 8, + "maxItems": 10, "minItems": 1, "type": "array" }, - "description": "The synthetic replies each call step receives, by step, in the order\nits attempts consume them: 1 to 8 per step. A reply after the first\nneeds a recovery for that step.", + "description": "The synthetic replies each call step receives, by step, in the order\nits attempts consume them, up to the runtime attempt limit. A reply\nafter the first needs a recovery for that step.", "maxProperties": 64, "propertyNames": { "$ref": "#/$defs/LocalId" @@ -289,7 +289,7 @@ }, { "additionalProperties": false, - "description": "No event arrives: the clock moves by the step's `timeoutSeconds`, and\nthe run continues at its `onTimeout`, or stops as `timed-out` when the\nstep has none. A deadline that passes first stops the run as\n`deadline-exceeded`.", + "description": "No event arrives: the clock moves by the step's `timeoutSeconds`, and\nthe run continues at its `onTimeout`, or stops as `timed-out` when the\nstep has none. An earlier service deadline takes `onDeadline`. The\nhard run deadline stops the run as `deadline-exceeded`.", "properties": { "type": { "const": "timeout", diff --git a/products/coordinator/scripts/test-postgres.sh b/products/coordinator/scripts/test-postgres.sh index d76cd40420..067cc72f5d 100755 --- a/products/coordinator/scripts/test-postgres.sh +++ b/products/coordinator/scripts/test-postgres.sh @@ -30,7 +30,7 @@ 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 read_lease_recovery \ + --test postgres_state --test service_deadline --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 \ diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 368477f87b..8c5598fd3b 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -1410,7 +1410,7 @@ "description": "Starts stay held after a restore until an operator releases the admission hold.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "admission.held", @@ -1429,7 +1429,7 @@ "description": "This instance runs in recovery-only mode and admits no runs. Send starts to the ordinary service.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "admission.recovery-only", @@ -1448,7 +1448,7 @@ "description": "The operation may have completed, but its audited result could not be released. Inspect the run before repeating the operation.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "audit.response-unavailable", @@ -1467,7 +1467,7 @@ "description": "The bearer credential is missing, invalid, or expired.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "authentication.refused", @@ -1486,7 +1486,7 @@ "description": "The run's original connection settings differ from this service's runtime configuration.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "binding.conflict", @@ -1505,7 +1505,7 @@ "description": "Connection settings that runs in progress use differ from this service's runtime configuration.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "binding.live-conflict", @@ -1524,7 +1524,7 @@ "description": "That step is not waiting.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "check.not-waiting", @@ -1543,7 +1543,7 @@ "description": "The workflow's workflow definition, one of its mappings or one of its functions refused the request.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "definition.refused", @@ -1562,7 +1562,7 @@ "description": "An apply changed the active packages after this service started. Restart it with the runtime file that apply used.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "deployment.activation-changed", @@ -1581,7 +1581,7 @@ "description": "The deployment's activation, runtime configuration or state key custody check failed.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "deployment.refused", @@ -1600,7 +1600,7 @@ "description": "An evaluation may have completed at its provider, so the run is never evaluated again.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "evaluation.uncertain", @@ -1619,7 +1619,7 @@ "description": "The signed event must satisfy the accepted canonical envelope and typed event contract.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "event.invalid", @@ -1638,7 +1638,7 @@ "description": "Restore the configured event source signing key and its enabled secret provider before retrying delivery.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "event.source-unavailable", @@ -1657,7 +1657,7 @@ "description": "The signed event exceeds the event value profile: 131072 canonical bytes, 32 levels of nesting, 4096 values, 256 items per array, 256 members per object, and 16384 bytes per string. Have the producer project fewer or smaller fields into the event.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "event.too-large", @@ -1676,7 +1676,7 @@ "description": "Send a fresh signed delivery from the exact configured source to its accepted trigger.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "event.unauthenticated", @@ -1695,7 +1695,7 @@ "description": "Execution is under restore hold until an operator releases it.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "execution.held", @@ -1714,7 +1714,7 @@ "description": "Supply an Idempotency-Key header of 1 to 256 bytes.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "idempotency.key-invalid", @@ -1733,7 +1733,7 @@ "description": "This idempotency key already started a run with different input.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "idempotency.key-reused", @@ -1752,7 +1752,7 @@ "description": "The start input does not satisfy the workflow's declared input schema and value bounds.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "input.invalid", @@ -1771,7 +1771,7 @@ "description": "An unkeyed mutation may have completed at its provider, so the run never sends it again.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "mutation.uncertain", @@ -1790,7 +1790,7 @@ "description": "The caller's policy does not allow this operation.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "operation.not-authorized", @@ -1809,7 +1809,7 @@ "description": "The original command's receipt can no longer be recovered. This is not evidence that the command was not sent.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "receipt.expired", @@ -1828,7 +1828,7 @@ "description": "The run's progress changed while it was being reconciled. Inspect it again.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "reconcile.raced", @@ -1847,7 +1847,7 @@ "description": "Reconcile applies only to a held mutation with a prepared command, its original binding and no active lease.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "reconcile.refused", @@ -1866,7 +1866,7 @@ "description": "The held operation has no original-result lookup.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "reconcile.unsupported", @@ -1885,7 +1885,7 @@ "description": "The request body exceeds the accepted size: 65536 bytes for a JSON request, or 1048576 bytes for a signed event delivery.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "request.body-too-large", @@ -1904,7 +1904,7 @@ "description": "The request could not be read as a Coordinator request.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "request.invalid", @@ -1923,7 +1923,7 @@ "description": "The list limit must be between 1 and 100.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "request.limit-out-of-range", @@ -1942,7 +1942,7 @@ "description": "The route exists but not for this method.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "request.method-not-allowed", @@ -1961,7 +1961,7 @@ "description": "The requested route does not exist.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "request.not-found", @@ -1980,7 +1980,7 @@ "description": "The reason or recovery reference must be a bounded reference without personal data or control characters.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "request.reason-invalid", @@ -1999,7 +1999,7 @@ "description": "The retention cutoff must be in the past and the limit between 1 and 100.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "request.retention-invalid", @@ -2018,7 +2018,7 @@ "description": "The request body could not be processed.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "request.unprocessable", @@ -2037,7 +2037,7 @@ "description": "The request body is not JSON.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "request.unsupported-media-type", @@ -2056,7 +2056,7 @@ "description": "This recovery action requires execution to be under restore hold.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "restore.hold-required", @@ -2075,7 +2075,7 @@ "description": "Held work no longer matches its protected definition or prepared command.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "restore.lease-incompatible", @@ -2094,7 +2094,7 @@ "description": "Execution history must be complete and the prior deployment fenced before recovery is reviewed.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "restore.review-required", @@ -2113,7 +2113,7 @@ "description": "Held work remains unresolved, so the restore hold cannot be released.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "restore.unresolved", @@ -2132,7 +2132,7 @@ "description": "Retry-same applies only to held work before its original deadline.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "retry.refused", @@ -2151,7 +2151,7 @@ "description": "This service's Coordinator version cannot interpret the run's pinned definition.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "run.definition-incompatible", @@ -2170,7 +2170,7 @@ "description": "No run with this identifier is visible to the caller.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "run.not-visible", @@ -2189,7 +2189,7 @@ "description": "Retention erased this run's payloads, so it can no longer be retried or reconciled.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "run.payload-erased", @@ -2208,7 +2208,7 @@ "description": "Coordinator could not complete the request.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "runtime.failure", @@ -2227,7 +2227,7 @@ "description": "A dependency of the Coordinator, such as its database, audit stream or token verifier, is unavailable. Try again after the service recovers.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "service.unavailable", @@ -2246,7 +2246,7 @@ "description": "This deployment does not serve the named workflow.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "workflow.not-served", @@ -2265,7 +2265,7 @@ "description": "The expected workflow revision differs from the active revision or the original admission.", "source": { "path": "crates/registry-coordinator/src/problem.rs", - "sha256": "8c1a94809b8a61a3b15e1d913d70b64f29bfefe15010e74be0cc1561ed854a24" + "sha256": "7faba89f92fc8873df2a32ce8865ef9fbd010433fbdcbae5b07133185b3d11cd" }, "problem": { "code": "workflow.revision-mismatch", @@ -4959,7 +4959,7 @@ }, "artifact": { "path": "products/breg/generated/authoring/registry-module.schema.json", - "sha256": "2948465f69e8dd8856e245826adb47addbb0b0b3ae5a4a3eece660dfa77ba060", + "sha256": "7beb51728884e600dccd3b47fd1c46e47936423883079f7cffb9dba147f0339a", "mediaType": "application/schema+json" } }, @@ -4977,7 +4977,7 @@ }, "artifact": { "path": "products/breg/generated/authoring/registry-project.schema.json", - "sha256": "cab7ff0a619109662b006d87d211743af4a9cc64977c0f01b64889e691d24a84", + "sha256": "85dd5a7829de9151091e6674c0cf9883d80f1bd6410a9a6585516e919d2cb67b", "mediaType": "application/schema+json" } }, @@ -5117,7 +5117,7 @@ "description": "Registry Casework fixture JSON Schema.", "source": { "path": "crates/registry-casework-core/src/schema.rs", - "sha256": "1604cf79433172ea4553b2f56365ea9e999a515aa3f16d60959cc652972ae185" + "sha256": "aae3e2891d651ad99989ae4720c08991d954c0f3232e194ec68d18e30642a711" }, "artifact": { "path": "products/casework/generated/fixture/fixture.schema.json", @@ -5135,7 +5135,7 @@ "description": "Registry Casework holiday set JSON Schema.", "source": { "path": "crates/registry-casework-core/src/schema.rs", - "sha256": "1604cf79433172ea4553b2f56365ea9e999a515aa3f16d60959cc652972ae185" + "sha256": "aae3e2891d651ad99989ae4720c08991d954c0f3232e194ec68d18e30642a711" }, "artifact": { "path": "products/casework/generated/holiday-set/holiday-set.schema.json", @@ -5153,11 +5153,11 @@ "description": "Registry Casework project JSON Schema.", "source": { "path": "crates/registry-casework-core/src/schema.rs", - "sha256": "1604cf79433172ea4553b2f56365ea9e999a515aa3f16d60959cc652972ae185" + "sha256": "aae3e2891d651ad99989ae4720c08991d954c0f3232e194ec68d18e30642a711" }, "artifact": { "path": "products/casework/generated/project/project.schema.json", - "sha256": "87a1a61127de6eb20c130f17b2d804c8b209c7343e7e4275f480f37833b83fcf", + "sha256": "3973b27d8d378de8f11877013c36c0bacc0aec6db003f70f1375c52b21857505", "mediaType": "application/schema+json" } }, @@ -5175,7 +5175,7 @@ }, "artifact": { "path": "products/casework/contracts/cli/report.schema.json", - "sha256": "2cbf5e3d2fd943abfa3777b63f204feac0dc0f5c02241a121aa3a135f3d420c7", + "sha256": "442c29fe6db3bd93c2a35a5303e341d8aaa70c95d58dcd589fee9fff529fdff4", "mediaType": "application/schema+json" } }, @@ -5207,11 +5207,11 @@ "description": "Registry Coordinator portable project authoring JSON Schema.", "source": { "path": "crates/registry-coordinator/src/authoring.rs", - "sha256": "032aa502fa63b5e7fe1cfb8f400be84d0802637409ac82ddf9f40152c482b91f" + "sha256": "8aff778324c1f05ca2615121980be6e3212d3e80f1bc99104b7256dfe18445a5" }, "artifact": { "path": "products/coordinator/generated/project/project.schema.json", - "sha256": "b22c06678f15e4a5aa028df61819cde8d37ce2311e1249a3a8fcc18d00aa3eab", + "sha256": "c4bc1e7a160804ec03dc958eed753e6cc59fb3132b702bf6bd43638f0c6829a1", "mediaType": "application/schema+json" } }, @@ -5243,11 +5243,11 @@ "description": "Registry Coordinator deterministic scenario authoring JSON Schema.", "source": { "path": "crates/registry-coordinator/src/scenarios.rs", - "sha256": "e20158e5382f690d46879161f32b5adda80385767b2c99b6efb11f166d43a394" + "sha256": "7fe149a20b3c14ec0d83f56bf387d2ee4dba24b55fb3a0f4f1c7c467b19425fc" }, "artifact": { "path": "products/coordinator/generated/scenarios/scenarios.schema.json", - "sha256": "e42e044eb828ff7e12f0a199fc95065c04baeadf91773c2d28ba9a1b0252090e", + "sha256": "7e69578e30ce4ca68bf5a4b895ef6306c6796d5af7ed5a586d6c5786a00570ab", "mediaType": "application/schema+json" } }, diff --git a/products/platform/MCP-GATEWAY.md b/products/platform/MCP-GATEWAY.md index 71acbb06c9..590d439f94 100644 --- a/products/platform/MCP-GATEWAY.md +++ b/products/platform/MCP-GATEWAY.md @@ -12,7 +12,7 @@ The service uses reviewed provider bindings, a fixed public tool ceiling and mai ## Protocol and tested surface -The lockfile resolves `rmcp` 3.7.0. The edge advertises MCP `2026-07-28` through the SDK's initialization result. It serves Streamable HTTP with tools/list, tools/call and protected-resource metadata. It does not advertise resources, prompts, sampling or model-host administration. +The lockfile resolves `rmcp` 3.9.0. The edge advertises MCP `2026-07-28` through the SDK's initialization result. It serves Streamable HTTP with tools/list, tools/call and protected-resource metadata. It does not advertise resources, prompts, sampling or model-host administration. The executable compatibility proof uses the maintained Rust MCP client and real routers. This is not a compatibility certification for every chat host or every protocol revision the SDK can recognize. A dependency update that moves the default protocol needs an explicit wire-contract review and test update. diff --git a/products/platform/fuzz/Cargo.lock b/products/platform/fuzz/Cargo.lock index ddfe10d55c..f4cddc876b 100644 --- a/products/platform/fuzz/Cargo.lock +++ b/products/platform/fuzz/Cargo.lock @@ -1314,9 +1314,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" dependencies = [ "cc", "pkg-config", @@ -2043,9 +2043,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.40.2" +version = "0.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" dependencies = [ "bitflags", "fallible-iterator",