diff --git a/crates/temper-cli/src/serve/actor_runtime.rs b/crates/temper-cli/src/serve/actor_runtime.rs index e2239f726..35ed8d23c 100644 --- a/crates/temper-cli/src/serve/actor_runtime.rs +++ b/crates/temper-cli/src/serve/actor_runtime.rs @@ -252,6 +252,13 @@ fn validate_actor_runtime_compatible( entity_type: &str, spec: &EntitySpec, ) -> Result<()> { + let runtime_invariants = temper_spec::automaton::compile_runtime_invariants(&spec.automaton); + if !runtime_invariants.is_empty() { + bail!( + "tenant {tenant} entity {entity_type} declares runtime-enforced invariants, which are not yet supported by --actor-runtime postgres" + ); + } + if !spec.integrations.is_empty() { bail!( "tenant {tenant} entity {entity_type} declares legacy integrations, which are not yet supported by --actor-runtime postgres" @@ -403,6 +410,64 @@ mod tests { assert!(err.to_string().contains("not yet supported")); } + #[test] + fn rejects_runtime_enforced_invariants_on_postgres_actor_runtime() { + let ioa = r#" +[automaton] +name = "Goal" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "goal" +type = "string" +initial = "" + +[[invariant]] +name = "GoalRequired" +when = ["Active"] +assert = "goal != ''" +"#; + let registry = registry_with("alpha", "Goal", ioa); + let error = collect_actor_runtime_definitions(®istry, &["Goal".into()]).unwrap_err(); + + assert!(error.to_string().contains("runtime-enforced invariants")); + } + + #[test] + fn accepts_model_only_counter_invariant_on_postgres_actor_runtime() { + let ioa = r#" +[automaton] +name = "Counter" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "items" +type = "counter" +initial = "1" + +[[action]] +name = "Add" +kind = "input" +from = ["Active"] +to = "Active" +effect = [{ type = "increment", var = "items" }] + +[[invariant]] +name = "HasItems" +when = ["Active"] +assert = "items > 0" +"#; + let registry = registry_with("alpha", "Counter", ioa); + let definitions = collect_actor_runtime_definitions(®istry, &["Counter".into()]) + .expect("model-only counter invariant remains actor-runtime compatible"); + + assert!(definitions.actor_backed_keys.contains("Counter")); + } + #[test] fn rejects_conflicting_same_named_specs_across_tenants() { let mut registry = registry_with("alpha", "Order", ORDER_IOA); diff --git a/crates/temper-cli/src/serve/mod.rs b/crates/temper-cli/src/serve/mod.rs index 8c7616e43..1760da202 100644 --- a/crates/temper-cli/src/serve/mod.rs +++ b/crates/temper-cli/src/serve/mod.rs @@ -755,21 +755,10 @@ async fn spawn_background_verification(state: &PlatformState, specs_dir: &str, t } } - // Build verification result - let verification_result = EntityVerificationResult { - all_passed: cascade_result.all_passed, - levels: cascade_result - .levels - .iter() - .map(|l| EntityLevelSummary { - level: format!("{:?}", l.level), - passed: l.passed, - summary: l.summary.clone(), - details: None, - }) - .collect(), - verified_at: chrono::Utc::now().to_rfc3339(), // determinism-ok: CLI code - }; + let verification_result = EntityVerificationResult::from_cascade( + &cascade_result, + chrono::Utc::now().to_rfc3339(), // determinism-ok: CLI code + ); let all_passed = cascade_result.all_passed; @@ -850,6 +839,8 @@ async fn spawn_background_verification(state: &PlatformState, specs_dir: &str, t summary: format!("Verification failed: {e}"), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: chrono::Utc::now().to_rfc3339(), // determinism-ok: CLI code }; diff --git a/crates/temper-jit/src/shadow.rs b/crates/temper-jit/src/shadow.rs index 8cd41174f..dd823d480 100644 --- a/crates/temper-jit/src/shadow.rs +++ b/crates/temper-jit/src/shadow.rs @@ -115,6 +115,7 @@ mod tests { entity_name: "Order".into(), states: vec!["Draft".into(), "Submitted".into(), "Cancelled".into()], initial_state: "Draft".into(), + state_var_initials: Default::default(), keys: vec![], vectors: vec![], rules: vec![ @@ -141,6 +142,8 @@ mod tests { ], state_var_metadata: Default::default(), composite_actions: Default::default(), + runtime_invariants: Default::default(), + model_protected_state_vars: Default::default(), rule_index: Default::default(), }; table.rebuild_index(); diff --git a/crates/temper-jit/src/swap.rs b/crates/temper-jit/src/swap.rs index 6b05c59f6..49b6d7a4e 100644 --- a/crates/temper-jit/src/swap.rs +++ b/crates/temper-jit/src/swap.rs @@ -86,6 +86,7 @@ mod tests { entity_name: name.to_string(), states: vec!["A".into(), "B".into()], initial_state: "A".into(), + state_var_initials: Default::default(), keys: vec![], vectors: vec![], rules: vec![TransitionRule { @@ -97,6 +98,8 @@ mod tests { }], state_var_metadata: Default::default(), composite_actions: Default::default(), + runtime_invariants: Default::default(), + model_protected_state_vars: Default::default(), rule_index: Default::default(), }; table.rebuild_index(); diff --git a/crates/temper-jit/src/table/builder.rs b/crates/temper-jit/src/table/builder.rs index bf48afc67..d82f4947a 100644 --- a/crates/temper-jit/src/table/builder.rs +++ b/crates/temper-jit/src/table/builder.rs @@ -4,34 +4,39 @@ //! translation layer in `temper-spec`. The shared layer eliminates duplicated //! guard/effect translation logic between JIT and verification paths. -use temper_spec::automaton::{self, Automaton, ResolvedEffect, ResolvedGuard, translate_actions}; +use temper_spec::automaton::{ + self, Automaton, ResolvedEffect, ResolvedGuard, parse_bool_initial, + parse_counter_initial_usize, translate_actions, +}; use super::guard::Guard; use super::types::{ - CompositeActionMetadata, CompositeCedarGate, Effect, SubWriteSpec, TransitionRule, - TransitionTable, + CompositeActionMetadata, CompositeCedarGate, Effect, StateVarInitialValue, SubWriteSpec, + TransitionRule, TransitionTable, }; impl TransitionTable { /// Build a TransitionTable from I/O Automaton TOML source. /// - /// Returns an error if the TOML fails to parse. Prefer this over - /// [`from_ioa_source`](Self::from_ioa_source) in production code - /// where parse errors should be propagated. + /// Returns an error if the TOML fails to parse or declares an unsupported + /// safety invariant. Prefer this over [`from_ioa_source`](Self::from_ioa_source) + /// in production code where construction errors should be propagated. pub fn try_from_ioa_source(ioa_toml: &str) -> Result { let automaton = automaton::parse_automaton(ioa_toml) .map_err(|e| format!("failed to parse I/O Automaton TOML: {e}"))?; - Ok(Self::from_automaton(&automaton)) + Self::try_from_automaton(&automaton) } /// Build a TransitionTable from I/O Automaton TOML source. /// /// # Panics /// - /// Panics if the TOML fails to parse. Use [`try_from_ioa_source`](Self::try_from_ioa_source) - /// for fallible construction. + /// Panics if the TOML fails to parse or declares an unsupported safety + /// invariant. Use [`try_from_ioa_source`](Self::try_from_ioa_source) for + /// fallible construction. pub fn from_ioa_source(ioa_toml: &str) -> Self { - Self::try_from_ioa_source(ioa_toml).expect("failed to parse I/O Automaton TOML") + Self::try_from_ioa_source(ioa_toml) + .expect("failed to build transition table from I/O Automaton TOML") } /// Build a TransitionTable directly from a parsed [`Automaton`]. @@ -39,7 +44,31 @@ impl TransitionTable { /// Each action becomes a [`TransitionRule`] with guards and effects /// derived from the IOA specification via the shared translation layer. /// Output actions are skipped (they don't transition state). + /// + /// # Panics + /// + /// Panics if the automaton declares an unsupported safety invariant. Use + /// [`try_from_automaton`](Self::try_from_automaton) for fallible construction. pub fn from_automaton(automaton: &Automaton) -> Self { + Self::try_from_automaton(automaton).expect("failed to build transition table") + } + + /// Build a TransitionTable directly from a parsed [`Automaton`]. + /// + /// Returns an error before constructing executable rules if any declared + /// safety invariant is outside the shared verification/runtime contract. + pub fn try_from_automaton(automaton: &Automaton) -> Result { + let unsupported = automaton::unsupported_safety_invariant_names(automaton); + if !unsupported.is_empty() { + return Err(format!( + "unsupported safety invariants: {}", + unsupported.join(", ") + )); + } + Ok(Self::build_from_automaton(automaton)) + } + + fn build_from_automaton(automaton: &Automaton) -> Self { let resolved_actions = translate_actions(automaton); let rules: Vec = resolved_actions @@ -120,6 +149,21 @@ impl TransitionTable { entity_name: automaton.automaton.name.clone(), states: automaton.automaton.states.clone(), initial_state: automaton.automaton.initial.clone(), + state_var_initials: automaton + .state + .iter() + .filter_map(|state| { + let initial = match state.var_type.as_str() { + "counter" => StateVarInitialValue::Counter(parse_counter_initial_usize( + &state.initial, + )), + "bool" => StateVarInitialValue::Bool(parse_bool_initial(&state.initial)), + "string" => StateVarInitialValue::String(state.initial.clone()), + _ => return None, + }; + Some((state.name.clone(), initial)) + }) + .collect(), rules, keys: automaton .keys @@ -142,11 +186,17 @@ impl TransitionTable { .collect(), state_var_metadata, composite_actions, + runtime_invariants: automaton::compile_runtime_invariants(automaton), + model_protected_state_vars: automaton::model_protected_state_var_names(automaton), rule_index, } } } +#[cfg(test)] +#[path = "builder_safety_tests.rs"] +mod builder_safety_tests; + /// Convert a shared [`ResolvedGuard`] to the JIT [`Guard`] type. fn convert_guard(guard: ResolvedGuard) -> Guard { match guard { @@ -221,82 +271,12 @@ fn convert_effect(effect: ResolvedEffect) -> Effect { } #[cfg(test)] -mod tests { - use super::*; +#[path = "builder_metadata_tests.rs"] +mod builder_metadata_tests; - #[test] - fn test_schedule_effect_maps_to_schedule_action() { - let spec = r#" -[automaton] -name = "OAuthToken" -states = ["Active", "Refreshing", "Expired"] -initial = "Active" - -[[action]] -name = "Activate" -from = ["Refreshing"] -to = "Active" -effect = [{ type = "schedule", action = "Refresh", delay_seconds = 2700 }] -"#; - - let table = TransitionTable::from_ioa_source(spec); - let rule = table.rules.iter().find(|r| r.name == "Activate").unwrap(); - - let has_schedule = rule.effects.iter().any(|e| { - matches!( - e, - Effect::ScheduleAction { action, delay_seconds } - if action == "Refresh" && *delay_seconds == 2700 - ) - }); - assert!( - has_schedule, - "expected ScheduleAction effect, got: {:?}", - rule.effects - ); - } - - #[test] - fn composite_metadata_is_registered_on_transition_table() { - let spec = r#" -[automaton] -name = "Repository" -states = ["Active"] -initial = "Active" - -[[action]] -name = "IngestPack" -kind = "Composite" -from = ["Active"] -to = "Active" -record_parent_event = false - -[[action.cedar_gate]] -principal = "request.principal" -resource = "this" -action = "Repository::IngestPack" - -[[action.sub_writes]] -target_entity = "Blob" -action = "Create" -generated_from = "pack_bytes" -"#; - - let table = TransitionTable::from_ioa_source(spec); - let metadata = table.composite_actions.get("IngestPack").unwrap(); - - assert_eq!( - metadata - .cedar_gate - .as_ref() - .map(|gate| gate.action.as_str()), - Some("Repository::IngestPack") - ); - assert!(!metadata.record_parent_event); - assert_eq!(metadata.sub_writes.len(), 1); - assert_eq!(metadata.sub_writes[0].target_entity, "Blob"); - } -} +#[cfg(test)] +#[path = "builder_initial_tests.rs"] +mod builder_initial_tests; #[cfg(test)] mod cross_entity_tests { diff --git a/crates/temper-jit/src/table/builder_initial_tests.rs b/crates/temper-jit/src/table/builder_initial_tests.rs new file mode 100644 index 000000000..1eab49dd1 --- /dev/null +++ b/crates/temper-jit/src/table/builder_initial_tests.rs @@ -0,0 +1,32 @@ +use super::{StateVarInitialValue, TransitionTable}; + +#[test] +fn state_initials_use_the_shared_model_and_runtime_parsers() { + let spec = r#" +[automaton] +name = "TypedInitials" +states = ["Ready"] +initial = "Ready" +allow_indefinite_states = ["Ready"] + +[[state]] +name = "enabled" +type = "bool" +initial = "YES" + +[[state]] +name = "retries" +type = "counter" +initial = " 3 " +"#; + + let table = TransitionTable::from_ioa_source(spec); + assert_eq!( + table.state_var_initials.get("enabled"), + Some(&StateVarInitialValue::Bool(true)) + ); + assert_eq!( + table.state_var_initials.get("retries"), + Some(&StateVarInitialValue::Counter(3)) + ); +} diff --git a/crates/temper-jit/src/table/builder_metadata_tests.rs b/crates/temper-jit/src/table/builder_metadata_tests.rs new file mode 100644 index 000000000..909acb7c1 --- /dev/null +++ b/crates/temper-jit/src/table/builder_metadata_tests.rs @@ -0,0 +1,74 @@ +use super::*; + +#[test] +fn test_schedule_effect_maps_to_schedule_action() { + let spec = r#" +[automaton] +name = "OAuthToken" +states = ["Active", "Refreshing", "Expired"] +initial = "Active" + +[[action]] +name = "Activate" +from = ["Refreshing"] +to = "Active" +effect = [{ type = "schedule", action = "Refresh", delay_seconds = 2700 }] +"#; + + let table = TransitionTable::from_ioa_source(spec); + let rule = table.rules.iter().find(|r| r.name == "Activate").unwrap(); + + let has_schedule = rule.effects.iter().any(|e| { + matches!( + e, + Effect::ScheduleAction { action, delay_seconds } + if action == "Refresh" && *delay_seconds == 2700 + ) + }); + assert!( + has_schedule, + "expected ScheduleAction effect, got: {:?}", + rule.effects + ); +} + +#[test] +fn composite_metadata_is_registered_on_transition_table() { + let spec = r#" +[automaton] +name = "Repository" +states = ["Active"] +initial = "Active" + +[[action]] +name = "IngestPack" +kind = "Composite" +from = ["Active"] +to = "Active" +record_parent_event = false + +[[action.cedar_gate]] +principal = "request.principal" +resource = "this" +action = "Repository::IngestPack" + +[[action.sub_writes]] +target_entity = "Blob" +action = "Create" +generated_from = "pack_bytes" +"#; + + let table = TransitionTable::from_ioa_source(spec); + let metadata = table.composite_actions.get("IngestPack").unwrap(); + + assert_eq!( + metadata + .cedar_gate + .as_ref() + .map(|gate| gate.action.as_str()), + Some("Repository::IngestPack") + ); + assert!(!metadata.record_parent_event); + assert_eq!(metadata.sub_writes.len(), 1); + assert_eq!(metadata.sub_writes[0].target_entity, "Blob"); +} diff --git a/crates/temper-jit/src/table/builder_safety_tests.rs b/crates/temper-jit/src/table/builder_safety_tests.rs new file mode 100644 index 000000000..4e403d064 --- /dev/null +++ b/crates/temper-jit/src/table/builder_safety_tests.rs @@ -0,0 +1,84 @@ +use super::*; + +const UNSUPPORTED_IOA: &str = r#" +[automaton] +name = "Workspace" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "used" +type = "counter" +initial = "0" + +[[state]] +name = "quota" +type = "counter" +initial = "1" + +[[invariant]] +name = "UnsupportedSafety" +when = ["Active"] +assert = "used ** quota" +"#; + +#[test] +fn fallible_source_constructor_rejects_unsupported_safety() { + let error = TransitionTable::try_from_ioa_source(UNSUPPORTED_IOA) + .expect_err("unsupported safety must not produce an executable table"); + + assert_eq!(error, "unsupported safety invariants: UnsupportedSafety"); +} + +#[test] +fn fallible_automaton_constructor_rejects_parameter_terminal_compound() { + let source = r#" +[automaton] +name = "ParameterTerminal" +states = ["Active", "Done"] +initial = "Active" +allow_indefinite_states = ["Active", "Done"] + +[[state]] +name = "budget" +type = "counter" +initial = "0" + +[[state]] +name = "safe" +type = "bool" +initial = "false" + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["amount"] +effect = [{ type = "set_counter_from_param", var = "budget", param = "amount" }] + +[[action]] +name = "Finish" +kind = "input" +from = ["Active"] +to = "Done" +guard = [{ type = "min_count", var = "budget", min = 2 }] + +[[invariant]] +name = "DoneSafety" +when = ["Done"] +assert = "safe && no_further_transitions" +"#; + let automaton = temper_spec::automaton::parse_automaton(source).expect("parse fixture"); + + let error = TransitionTable::try_from_automaton(&automaton) + .expect_err("parameter terminal compound must fail closed"); + assert_eq!(error, "unsupported safety invariants: DoneSafety"); +} + +#[test] +#[should_panic(expected = "unsupported safety invariants: UnsupportedSafety")] +fn infallible_constructor_panics_instead_of_dropping_unsupported_safety() { + TransitionTable::from_ioa_source(UNSUPPORTED_IOA); +} diff --git a/crates/temper-jit/src/table/evaluate.rs b/crates/temper-jit/src/table/evaluate.rs index 3794f904b..7403494ff 100644 --- a/crates/temper-jit/src/table/evaluate.rs +++ b/crates/temper-jit/src/table/evaluate.rs @@ -236,6 +236,95 @@ mod tests { assert_eq!(r.new_state, "Submitted"); } + #[test] + fn serde_roundtrip_preserves_runtime_invariant_contract() { + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Goal" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "goal" +type = "string" +initial = "" + +[[invariant]] +name = "GoalRequired" +when = ["Active"] +assert = "goal != ''" +"#, + ); + + let json = serde_json::to_string(&table).expect("serialize runtime contract"); + let restored: TransitionTable = + serde_json::from_str(&json).expect("deserialize runtime contract"); + + assert_eq!(restored.runtime_invariants, table.runtime_invariants); + assert_eq!(restored.runtime_invariants.len(), 1); + assert_eq!( + restored.runtime_invariants[0].enforcement_version, + temper_spec::automaton::RUNTIME_INVARIANT_ENFORCEMENT_VERSION + ); + } + + #[test] + fn serialized_table_without_runtime_contract_field_is_rejected() { + let table = order_table(); + let mut value = serde_json::to_value(table).expect("serialize table"); + value + .as_object_mut() + .expect("table JSON object") + .remove("runtime_invariants"); + + let restored = serde_json::from_value::(value); + assert!( + restored.is_err(), + "pre-contract artifacts must be rebuilt from source" + ); + } + + #[test] + fn serialized_table_without_model_protection_contract_is_rejected() { + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Payment" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "payment_captured" +type = "bool" +initial = "true" + +[[invariant]] +name = "PaymentCaptured" +when = ["Active"] +assert = "payment_captured" +"#, + ); + assert!( + table + .model_protected_state_vars + .contains("payment_captured") + ); + let mut value = serde_json::to_value(table).expect("serialize table"); + value + .as_object_mut() + .expect("table JSON object") + .remove("model_protected_state_vars"); + + let restored = serde_json::from_value::(value); + assert!( + restored.is_err(), + "pre-protection artifacts must be rebuilt from source" + ); + } + #[test] fn guard_rejection_carries_guard_failure_identity() { let table = order_table(); diff --git a/crates/temper-jit/src/table/types.rs b/crates/temper-jit/src/table/types.rs index e18fe1ffe..7a0afb305 100644 --- a/crates/temper-jit/src/table/types.rs +++ b/crates/temper-jit/src/table/types.rs @@ -5,7 +5,7 @@ //! //! Guard conditions and their evaluation live in [`super::guard`]. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; @@ -38,6 +38,17 @@ pub struct DeclaredVector { pub metric: String, } +/// Typed initial value for a declared runtime state variable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum StateVarInitialValue { + /// Counter initial value. + Counter(usize), + /// Boolean initial value. + Bool(bool), + /// String initial value. + String(String), +} + /// A transition table: state machine transitions as DATA, not code. /// Can be hot-swapped per-actor without restart. #[derive(Debug, Clone, Serialize)] @@ -48,6 +59,8 @@ pub struct TransitionTable { pub states: Vec, /// The state an entity starts in. pub initial_state: String, + /// Typed initial values for declared counter, boolean, and string variables. + pub state_var_initials: BTreeMap, /// Ordered list of transition rules. pub rules: Vec, /// ADR-0153: declared unique/alternate keys the kernel indexes for @@ -65,6 +78,11 @@ pub struct TransitionTable { /// Composite-action metadata keyed by action name (ADR-0040). #[serde(default)] pub composite_actions: BTreeMap, + /// Safety assertions enforced atomically against tentative runtime state. + pub runtime_invariants: Vec, + /// Counter and boolean variables governed by model-proved transition + /// effects. Caller payloads cannot directly mutate these variables. + pub model_protected_state_vars: BTreeSet, /// Pre-built index: action name → indices into `rules`. /// /// Eliminates the O(N) linear scan + Vec allocation in [`evaluate_ctx()`]. @@ -154,11 +172,14 @@ impl<'de> Deserialize<'de> for TransitionTable { entity_name: String, states: Vec, initial_state: String, + state_var_initials: BTreeMap, rules: Vec, #[serde(default)] state_var_metadata: BTreeMap, #[serde(default)] composite_actions: BTreeMap, + runtime_invariants: Vec, + model_protected_state_vars: BTreeSet, #[serde(default)] keys: Vec, #[serde(default)] @@ -170,11 +191,14 @@ impl<'de> Deserialize<'de> for TransitionTable { entity_name: raw.entity_name, states: raw.states, initial_state: raw.initial_state, + state_var_initials: raw.state_var_initials, rules: raw.rules, keys: raw.keys, vectors: raw.vectors, state_var_metadata: raw.state_var_metadata, composite_actions: raw.composite_actions, + runtime_invariants: raw.runtime_invariants, + model_protected_state_vars: raw.model_protected_state_vars, rule_index: BTreeMap::new(), }; table.rebuild_index(); @@ -286,6 +310,7 @@ mod tests { entity_name: "TestEntity".to_string(), states: vec!["Draft".to_string(), "Active".to_string()], initial_state: "Draft".to_string(), + state_var_initials: BTreeMap::new(), keys: vec![], vectors: vec![], rules: vec![ @@ -313,6 +338,8 @@ mod tests { ], state_var_metadata: BTreeMap::new(), composite_actions: BTreeMap::new(), + runtime_invariants: Vec::new(), + model_protected_state_vars: BTreeSet::new(), rule_index: BTreeMap::new(), }; table.rebuild_index(); diff --git a/crates/temper-platform/src/bootstrap.rs b/crates/temper-platform/src/bootstrap.rs index 8cbbdd176..24deff61a 100644 --- a/crates/temper-platform/src/bootstrap.rs +++ b/crates/temper-platform/src/bootstrap.rs @@ -14,6 +14,7 @@ use temper_spec::automaton; use temper_spec::csdl::parse_csdl; use temper_store_turso::spec_content_hash; use temper_verify::cascade::VerificationCascade; +use temper_verify::{runtime_enforcement_warnings_from_ioa, unsupported_invariant_errors_from_ioa}; use crate::state::PlatformState; @@ -140,13 +141,22 @@ fn bootstrap_tenant_specs_inner( for (entity_type, ioa_source) in specs { automaton::parse_automaton(ioa_source) .unwrap_or_else(|e| panic!("{label} spec {entity_type} failed to parse: {e}")); + let capability_errors = unsupported_invariant_errors_from_ioa(ioa_source) + .unwrap_or_else(|e| panic!("{label} spec {entity_type} failed to build: {e}")); + assert!( + capability_errors.is_empty(), + "{label} spec {entity_type} declares unsupported safety invariants: {capability_errors:?}" + ); } // Hash-gated verification: only run the cascade for specs whose // content has changed since the last successful verification. let mut spec_hashes = Vec::with_capacity(specs.len()); + let mut runtime_warnings_by_entity = BTreeMap::new(); for (entity_type, ioa_source) in specs { let hash = spec_content_hash(ioa_source); + let runtime_warnings = runtime_enforcement_warnings_from_ioa(ioa_source) + .unwrap_or_else(|e| panic!("{label} spec {entity_type} failed to build: {e}")); let already_verified = verified_cache .get(*entity_type) .is_some_and(|(cached_hash, verified)| *verified && cached_hash == &hash); @@ -175,6 +185,7 @@ fn bootstrap_tenant_specs_inner( "{label} spec {entity_type} failed verification cascade" ); } + runtime_warnings_by_entity.insert(entity_type.to_string(), runtime_warnings); spec_hashes.push((entity_type.to_string(), hash)); } @@ -210,6 +221,11 @@ fn bootstrap_tenant_specs_inner( summary: "Pre-verified at bootstrap".to_string(), details: None, }], + warnings: runtime_warnings_by_entity + .get(*entity_type) + .cloned() + .unwrap_or_default(), + errors: Vec::new(), verified_at: now.clone(), }), ); @@ -314,6 +330,22 @@ pub(crate) async fn persist_bootstrap_verification( wrote_specs = true; // Mark as verified (bootstrap panics on failure, so all specs here passed). + let runtime_warnings = runtime_enforcement_warnings_from_ioa(ioa_source) + .expect("bootstrap IOA already passed capability preflight"); + let verification_result = EntityVerificationResult { + all_passed: true, + levels: vec![EntityLevelSummary { + level: "Bootstrap".to_string(), + passed: true, + summary: "Pre-verified at bootstrap".to_string(), + details: None, + }], + warnings: runtime_warnings, + errors: Vec::new(), + verified_at: temper_runtime::scheduler::sim_now().to_rfc3339(), + }; + let verification_result_json = serde_json::to_string(&verification_result) + .expect("bootstrap verification result must serialize"); if let Err(e) = store .persist_spec_verification( tenant, @@ -323,7 +355,7 @@ pub(crate) async fn persist_bootstrap_verification( verified: true, levels_passed: None, levels_total: None, - verification_result_json: None, + verification_result_json: Some(&verification_result_json), }, ) .await @@ -772,6 +804,119 @@ initial = "Created" ); } + #[test] + #[should_panic(expected = "declares unsupported safety invariants")] + fn cached_trusted_spec_cannot_bypass_invariant_capability_gate() { + let source = r#" +[automaton] +name = "Workspace" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "used_bytes" +type = "counter" +initial = "0" + +[[state]] +name = "quota_limit" +type = "counter" +initial = "1" + +[[invariant]] +name = "WithinQuota" +when = ["Active"] +assert = "used_bytes ** quota_limit" +"#; + let mut verified_cache = BTreeMap::new(); + verified_cache.insert("Workspace".to_string(), (spec_content_hash(source), true)); + let state = PlatformState::new(None); + + bootstrap_tenant_specs( + &state, + "cached-tenant", + SYSTEM_CSDL, + &[("Workspace", source)], + BootstrapTenantSpecsOptions { + merge: false, + label: "CachedBundle", + verified_cache: &verified_cache, + cross_invariants_source: None, + verification_mode: BootstrapSpecVerificationMode::TrustBundle, + }, + ); + } + + #[test] + fn cached_bootstrap_status_retains_runtime_enforcement_disclosure() { + let csdl = r#" + + + + + + + + + + + + +"#; + let source = r#" +[automaton] +name = "Widget" +states = ["Active"] +initial = "Active" + +[[state]] +name = "budget" +type = "counter" +initial = "0" + +[[action]] +name = "SetBudget" +from = ["Active"] +to = "Active" +params = ["amount"] +effect = [{ type = "set_counter_from_param", var = "budget", param = "amount" }] + +[[invariant]] +name = "BudgetBound" +when = ["Active"] +assert = "budget <= 10" +"#; + let mut verified_cache = BTreeMap::new(); + verified_cache.insert("Widget".to_string(), (spec_content_hash(source), true)); + let state = PlatformState::new(None); + + bootstrap_tenant_specs( + &state, + "cached-runtime-tenant", + csdl, + &[("Widget", source)], + BootstrapTenantSpecsOptions { + merge: false, + label: "CachedRuntime", + verified_cache: &verified_cache, + cross_invariants_source: None, + verification_mode: BootstrapSpecVerificationMode::TrustBundle, + }, + ); + + let registry = state.registry.read().unwrap(); + let status = registry + .get_verification_status(&TenantId::new("cached-runtime-tenant"), "Widget") + .expect("cached runtime status"); + let VerificationStatus::Completed(result) = status else { + panic!("expected completed cached status"); + }; + assert_eq!(result.warnings.len(), 1); + assert!(result.warnings[0].contains("BudgetBound")); + assert!(result.warnings[0].contains("version 2")); + } + #[test] fn test_agent_specs_count() { assert_eq!(AGENT_SPECS.len(), 8); diff --git a/crates/temper-platform/src/deploy.rs b/crates/temper-platform/src/deploy.rs index 533f83ada..62d836e71 100644 --- a/crates/temper-platform/src/deploy.rs +++ b/crates/temper-platform/src/deploy.rs @@ -17,6 +17,7 @@ use temper_spec::automaton; use temper_spec::csdl::parse_csdl; use temper_store_turso::spec_content_hash; use temper_verify::cascade::{CascadeResult, VerificationCascade}; +use temper_verify::unsupported_invariant_errors_from_ioa; use crate::protocol::{PlatformEvent, VerifyStepStatus}; use crate::state::PlatformState; @@ -209,6 +210,14 @@ impl DeployPipeline { // and already verified in the registry. let content_hash = spec_content_hash(&entity.ioa_source); let tenant_id = TenantId::new(&input.tenant_name); + // Capability validation is deliberately evaluated before cached pass + // state. Verification artifacts created before TVE001 existed cannot + // make an unsupported safety claim deployable forever. + let capability_errors = unsupported_invariant_errors_from_ioa(&entity.ioa_source) + .expect("parsed deployment IOA must build a verification model"); + let runtime_warnings = + temper_verify::runtime_enforcement_warnings_from_ioa(&entity.ioa_source) + .expect("parsed deployment IOA must build runtime enforcement disclosures"); let already_verified = { let registry = state.registry.read().unwrap(); // ci-ok: infallible lock registry @@ -221,7 +230,26 @@ impl DeployPipeline { .is_some_and(|vs| vs.is_passed()) }; - let result = if already_verified { + let result = if !capability_errors.is_empty() { + state.broadcast(PlatformEvent::VerifyStatus { + tenant: input.tenant_name.clone(), + level: "Invariant Capability".into(), + status: VerifyStepStatus::Failed, + summary: format!( + "Spec {} declares {} unsupported safety invariant(s)", + entity.entity_type, + capability_errors.len() + ), + }); + CascadeResult { + all_passed: false, + levels: vec![], + warnings: runtime_warnings, + errors: capability_errors, + reachable_paths: None, + composite_report: None, + } + } else if already_verified { tracing::info!( "Spec {} unchanged (hash={}…), skipping cascade", entity.entity_type, @@ -237,7 +265,8 @@ impl DeployPipeline { CascadeResult { all_passed: true, levels: vec![], - warnings: vec![], + warnings: runtime_warnings, + errors: vec![], reachable_paths: None, composite_report: None, } @@ -470,6 +499,47 @@ name = "Complete" from = ["InProgress"] to = "Done" kind = "internal" +"#; + + const UNSUPPORTED_TASK_IOA: &str = r#" +[automaton] +name = "Task" +initial = "Open" +states = ["Open"] +allow_indefinite_states = ["Open"] + +[[state]] +name = "used_bytes" +type = "counter" +initial = "0" + +[[state]] +name = "quota_limit" +type = "counter" +initial = "1" + +[[invariant]] +name = "WithinQuota" +when = ["Open"] +assert = "used_bytes ** quota_limit" +"#; + + const RUNTIME_ENFORCED_TASK_IOA: &str = r#" +[automaton] +name = "Task" +initial = "Open" +states = ["Open"] +allow_indefinite_states = ["Open"] + +[[state]] +name = "title" +type = "string" +initial = "ready" + +[[invariant]] +name = "TitleRequired" +when = ["Open"] +assert = "title != ''" "#; const TASK_CSDL: &str = r#" @@ -577,6 +647,77 @@ kind = "internal" assert!(cascade.all_passed); } + #[test] + fn deploy_rejects_unsupported_before_registry_activation() { + let state = PlatformState::new(None); + + let input = DeployInput { + tenant_name: "cached-tenant".into(), + csdl_xml: TASK_CSDL.into(), + entities: vec![EntitySpecSource { + entity_type: "Task".into(), + ioa_source: UNSUPPORTED_TASK_IOA.into(), + }], + wasm_modules: std::collections::BTreeMap::new(), + }; + let result = DeployPipeline::verify_and_deploy(&state, &input); + + assert!(!result.success); + let cascade = result.entity_results[0] + .cascade + .as_ref() + .expect("capability result"); + assert!(!cascade.all_passed); + assert!(cascade.levels.is_empty()); + assert_eq!(cascade.errors[0].code, "TVE001"); + } + + #[test] + fn unchanged_cached_pass_preserves_runtime_enforcement_disclosure() { + let state = PlatformState::new(None); + let tenant = TenantId::new("cached-runtime-tenant"); + { + let mut registry = state.registry.write().unwrap(); + registry.register_tenant( + tenant.clone(), + parse_csdl(TASK_CSDL).expect("parse CSDL"), + TASK_CSDL.to_string(), + &[("Task", RUNTIME_ENFORCED_TASK_IOA)], + ); + registry.set_verification_status( + &tenant, + "Task", + temper_server::registry::VerificationStatus::Completed( + temper_server::registry::EntityVerificationResult { + all_passed: true, + levels: vec![], + warnings: Vec::new(), + errors: Vec::new(), + verified_at: "runtime-cache".to_string(), + }, + ), + ); + } + + let input = DeployInput { + tenant_name: "cached-runtime-tenant".into(), + csdl_xml: TASK_CSDL.into(), + entities: vec![EntitySpecSource { + entity_type: "Task".into(), + ioa_source: RUNTIME_ENFORCED_TASK_IOA.into(), + }], + wasm_modules: std::collections::BTreeMap::new(), + }; + let result = DeployPipeline::verify_and_deploy(&state, &input); + + assert!(result.success); + let cascade = result.entity_results[0].cascade.as_ref().unwrap(); + assert!(cascade.levels.is_empty(), "unchanged spec should use cache"); + assert!(cascade.warnings.iter().any(|warning| { + warning.contains("TitleRequired") && warning.contains("not model-proved") + })); + } + #[test] fn test_deploy_pipeline_broadcasts_verify_status() { let state = PlatformState::new(None); diff --git a/crates/temper-platform/src/os_apps/mod_test.rs b/crates/temper-platform/src/os_apps/mod_test.rs index 42f887e25..a1019f08f 100644 --- a/crates/temper-platform/src/os_apps/mod_test.rs +++ b/crates/temper-platform/src/os_apps/mod_test.rs @@ -710,6 +710,8 @@ async fn test_reconcile_os_app_repairs_spec_content_drift_despite_matching_diges summary: "Spec drift should still force app reconcile".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: verified_at.clone(), }), ); @@ -813,6 +815,8 @@ async fn test_reconcile_os_app_repairs_entity_set_map_from_matching_digest() { summary: "Preserved verification for skipped reconcile".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: verified_at.clone(), }), ); @@ -1263,6 +1267,8 @@ async fn test_reinstall_of_skipped_specs_repairs_entity_set_map() { summary: "Preserved verification for skipped reinstall".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: verified_at.clone(), }), ); diff --git a/crates/temper-platform/src/os_apps/runtime_heal.rs b/crates/temper-platform/src/os_apps/runtime_heal.rs index aad304590..736e41a54 100644 --- a/crates/temper-platform/src/os_apps/runtime_heal.rs +++ b/crates/temper-platform/src/os_apps/runtime_heal.rs @@ -2,6 +2,7 @@ use temper_runtime::tenant::TenantId; use temper_server::platform_store::{PlatformStore, SpecVerificationUpdate}; use temper_server::registry::{EntityLevelSummary, EntityVerificationResult, VerificationStatus}; use temper_spec::csdl::parse_csdl; +use temper_verify::runtime_enforcement_warnings_from_ioa; use super::AppBundle; use crate::state::PlatformState; @@ -157,29 +158,23 @@ async fn mark_app_specs_restored_from_matching_digest( let tenant_id = TenantId::new(tenant); let verified_at = temper_runtime::scheduler::sim_now().to_rfc3339(); - let result = EntityVerificationResult { - all_passed: true, - levels: vec![EntityLevelSummary { - level: "BundleDigest".to_string(), - passed: true, - summary: format!("Restored from matching OS app bundle digest ({app_name})"), - details: None, - }], - verified_at, - }; { let mut registry = state.registry.write().expect("Spec registry lock poisoned"); - for (entity_type, _) in &bundle.specs { + for (entity_type, ioa_source) in &bundle.specs { + let result = restored_bundle_verification(app_name, ioa_source, &verified_at); registry.set_verification_status( &tenant_id, entity_type, - VerificationStatus::Restored(result.clone()), + VerificationStatus::Restored(result), ); } } - for (entity_type, _) in &bundle.specs { + for (entity_type, ioa_source) in &bundle.specs { + let result = restored_bundle_verification(app_name, ioa_source, &verified_at); + let result_json = serde_json::to_string(&result) + .expect("restored bundle verification result must serialize"); if let Err(error) = ps .persist_spec_verification( tenant, @@ -189,7 +184,7 @@ async fn mark_app_specs_restored_from_matching_digest( verified: true, levels_passed: Some(1), levels_total: Some(1), - verification_result_json: None, + verification_result_json: Some(&result_json), }, ) .await @@ -205,6 +200,26 @@ async fn mark_app_specs_restored_from_matching_digest( } } +fn restored_bundle_verification( + app_name: &str, + ioa_source: &str, + verified_at: &str, +) -> EntityVerificationResult { + EntityVerificationResult { + all_passed: true, + levels: vec![EntityLevelSummary { + level: "BundleDigest".to_string(), + passed: true, + summary: format!("Restored from matching OS app bundle digest ({app_name})"), + details: None, + }], + warnings: runtime_enforcement_warnings_from_ioa(ioa_source) + .expect("restored bundle IOA must retain a valid runtime safety contract"), + errors: Vec::new(), + verified_at: verified_at.to_string(), + } +} + pub(crate) async fn restore_app_specs_from_matching_digest( state: &PlatformState, ps: &dyn PlatformStore, diff --git a/crates/temper-platform/tests/compile_first_e2e.rs b/crates/temper-platform/tests/compile_first_e2e.rs index 615051244..081fdcea2 100644 --- a/crates/temper-platform/tests/compile_first_e2e.rs +++ b/crates/temper-platform/tests/compile_first_e2e.rs @@ -104,6 +104,8 @@ fn build_user_registry(tenant: &str, ioa_specs: &[(&str, &str)]) -> SpecRegistry summary: "Pre-verified".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-02-18T00:00:00Z".to_string(), }), ); @@ -263,6 +265,8 @@ async fn e2e_compile_first_two_tenants() { summary: "Pre-verified".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-02-18T00:00:00Z".to_string(), }), ); diff --git a/crates/temper-platform/tests/identity_e2e.rs b/crates/temper-platform/tests/identity_e2e.rs index c3f90627b..16c9de36d 100644 --- a/crates/temper-platform/tests/identity_e2e.rs +++ b/crates/temper-platform/tests/identity_e2e.rs @@ -15,6 +15,7 @@ use axum::http::{Request, StatusCode}; use std::collections::BTreeMap; use temper_platform::bootstrap::{bootstrap_agent_specs, bootstrap_system_tenant}; use temper_platform::state::PlatformState; +use temper_runtime::scheduler::sim_now; use temper_runtime::tenant::TenantId; use temper_server::identity::{IdentityResolver, hash_token}; use temper_server::request_context::AgentContext; @@ -428,7 +429,10 @@ async fn e2e_http_identity_resolve_exempt_from_auth() { /// Bearer auth: valid agent credential resolves identity on HTTP requests. #[tokio::test] async fn e2e_http_agent_credential_auth() { - let app = identity_test_router(); + let mut state = identity_test_state(); + state.api_token = Some("admin-test-key".to_string()); + let server = state.server.clone(); + let app = temper_platform::router::build_platform_router(state); // 1. Create AgentType (as admin) let response = app @@ -470,6 +474,31 @@ async fn e2e_http_agent_credential_auth() { let agent_key = "tmpr_http-auth-test-key"; let key_hash = hash_token(agent_key); + // Deterministically model a concurrent read at the actor-spawn boundary: + // the pristine credential actor exists, but validation/publication has not + // completed. Neither direct nor collection reads may discover it. + server + .get_or_spawn_tenant_actor_with_fields( + &TenantId::new(TEST_TENANT), + "AgentCredential", + &key_hash, + serde_json::json!({}), + ) + .expect("credential actor should spawn"); + let response = app + .clone() + .oneshot( + Request::get(format!("/tdata/AgentCredentials('{key_hash}')")) + .header("Authorization", "Bearer admin-test-key") + .header("X-Temper-Principal-Kind", "admin") + .header("X-Tenant-Id", TEST_TENANT) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let response = app .clone() .oneshot( @@ -485,6 +514,29 @@ async fn e2e_http_agent_credential_auth() { .unwrap(); assert_eq!(response.status(), StatusCode::CREATED); + let response = app + .clone() + .oneshot( + Request::get("/tdata/AgentCredentials") + .header("Authorization", "Bearer admin-test-key") + .header("X-Temper-Principal-Kind", "admin") + .header("X-Tenant-Id", TEST_TENANT) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let collection = body_json(response).await; + assert!( + !collection["value"] + .as_array() + .into_iter() + .flatten() + .any(|item| item["entity_id"] == key_hash), + "uninitialized credential must not be query-visible" + ); + // Issue the credential let response = app .clone() @@ -507,6 +559,92 @@ async fn e2e_http_agent_credential_auth() { let json = body_json(response).await; assert_eq!(json["status"], "Active"); + let mut initialized = None; + for _ in 0..50 { + let response = app + .clone() + .oneshot( + Request::get("/tdata/AgentCredentials") + .header("Authorization", "Bearer admin-test-key") + .header("X-Temper-Principal-Kind", "admin") + .header("X-Tenant-Id", TEST_TENANT) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let collection = body_json(response).await; + initialized = collection["value"] + .as_array() + .into_iter() + .flatten() + .find(|item| item["entity_id"] == key_hash) + .cloned(); + if initialized.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let initialized = initialized.expect("initialized credential must become query-visible"); + assert_eq!(initialized["status"], "Active"); + assert_eq!(initialized["fields"]["agent_type_id"], "http-cc-type"); + + // In-memory-only initialized state has no replay source. Idle passivation + // must retain the live actor, and both keyed and collection reads must keep + // returning the initialized state rather than a pristine invalid shell. + let actor_key = format!("{TEST_TENANT}:AgentCredential:{key_hash}"); + server.last_accessed.write().unwrap().insert( + actor_key.clone(), + sim_now() - chrono::Duration::seconds(600), + ); + server.passivate_idle_actors().await; + assert!( + server + .actor_registry + .read() + .unwrap() + .contains_key(&actor_key) + ); + + let response = app + .clone() + .oneshot( + Request::get(format!("/tdata/AgentCredentials('{key_hash}')")) + .header("Authorization", "Bearer admin-test-key") + .header("X-Temper-Principal-Kind", "admin") + .header("X-Tenant-Id", TEST_TENANT) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["status"], "Active"); + + let response = app + .clone() + .oneshot( + Request::get("/tdata/AgentCredentials") + .header("Authorization", "Bearer admin-test-key") + .header("X-Temper-Principal-Kind", "admin") + .header("X-Tenant-Id", TEST_TENANT) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let collection = body_json(response).await; + assert!( + collection["value"] + .as_array() + .into_iter() + .flatten() + .any(|item| item["entity_id"] == key_hash && item["status"] == "Active"), + "initialized credential must remain collection-visible after passivation sweep" + ); + // 3. Use the agent credential as Bearer token — should be accepted let response = app .clone() diff --git a/crates/temper-runtime/src/actor/cell.rs b/crates/temper-runtime/src/actor/cell.rs index 5bbca1c24..3a289da52 100644 --- a/crates/temper-runtime/src/actor/cell.rs +++ b/crates/temper-runtime/src/actor/cell.rs @@ -102,6 +102,10 @@ impl ActorCell { break 'message_loop false; } } + if ctx.stop_requested { + rx.close(); + break 'message_loop false; + } } Envelope::Ask { msg, reply } => { ctx.reply_channel = Some(reply); @@ -118,6 +122,10 @@ impl ActorCell { } } ctx.reply_channel = None; + if ctx.stop_requested { + rx.close(); + break 'message_loop false; + } } Envelope::Signal(signal) => match signal { SystemSignal::Stop | SystemSignal::PoisonPill => { diff --git a/crates/temper-runtime/src/actor/context.rs b/crates/temper-runtime/src/actor/context.rs index 6a8f6a703..765a93786 100644 --- a/crates/temper-runtime/src/actor/context.rs +++ b/crates/temper-runtime/src/actor/context.rs @@ -20,6 +20,9 @@ pub struct ActorContext { /// Children spawned by this actor. pub(crate) children: HashMap>, // determinism-ok: key-based lookup only; iteration order not observed + /// Whether the actor should stop after the current message finishes. + pub(crate) stop_requested: bool, + _phantom: std::marker::PhantomData, } @@ -29,6 +32,7 @@ impl ActorContext { id, reply_channel: None, children: HashMap::new(), // determinism-ok: map order is not used + stop_requested: false, _phantom: std::marker::PhantomData, } } @@ -58,6 +62,17 @@ impl ActorContext { self.reply_channel.is_some() } + /// Reply to the current ask and stop after the handler returns. + /// + /// The stop is requested only if the reply is delivered. This lets callers + /// retry a timed-out operation without leaving the actor stopped behind a + /// stale registry reference. + pub fn reply_and_stop(&mut self, response: R) { + if let Some(tx) = self.reply_channel.take() { + self.stop_requested = tx.send(Ok(Box::new(response))).is_ok(); + } + } + /// Register a child actor ref (for supervision tracking). pub fn register_child(&mut self, name: &str, child: ActorRef) { self.children.insert(name.to_string(), Box::new(child)); @@ -70,3 +85,73 @@ impl ActorContext { .and_then(|boxed| boxed.downcast_ref::>()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::supervision::SupervisionStrategy; + + struct TestActor; + + #[derive(Debug)] + struct TestMsg; + + impl Message for TestMsg {} + + impl Actor for TestActor { + type Msg = TestMsg; + type State = (); + + async fn pre_start( + &self, + _ctx: &mut ActorContext, + ) -> Result { + Ok(()) + } + + async fn handle( + &self, + _msg: TestMsg, + _state: &mut Self::State, + _ctx: &mut ActorContext, + ) -> Result<(), ActorError> { + Ok(()) + } + + fn supervision_strategy(&self) -> SupervisionStrategy { + SupervisionStrategy::Stop + } + + async fn post_stop(&self, _state: Self::State, _ctx: &mut ActorContext) {} + } + + #[test] + fn reply_and_stop_requires_a_live_ask_receiver() { + let id = ActorId::new("test", "test/reply-and-stop"); + let mut ctx = ActorContext::::new(id); + let (reply, receiver) = oneshot::channel(); + ctx.reply_channel = Some(reply); + drop(receiver); + + ctx.reply_and_stop("done"); + + assert!(!ctx.stop_requested); + } + + #[test] + fn reply_and_stop_requests_stop_after_delivery() { + let id = ActorId::new("test", "test/reply-and-stop"); + let mut ctx = ActorContext::::new(id); + let (reply, mut receiver) = oneshot::channel(); + ctx.reply_channel = Some(reply); + + ctx.reply_and_stop("done"); + + assert!(ctx.stop_requested); + let response = receiver + .try_recv() + .expect("reply should be delivered") + .expect("reply should succeed"); + assert_eq!(*response.downcast::<&str>().expect("reply type"), "done"); + } +} diff --git a/crates/temper-runtime/src/mailbox/mod.rs b/crates/temper-runtime/src/mailbox/mod.rs index a889eaacb..b4db49adc 100644 --- a/crates/temper-runtime/src/mailbox/mod.rs +++ b/crates/temper-runtime/src/mailbox/mod.rs @@ -85,6 +85,11 @@ impl MailboxReceiver { pub async fn recv(&mut self) -> Option> { self.inner.recv().await } + + /// Close the receiving side so no messages can enter during actor cleanup. + pub(crate) fn close(&mut self) { + self.inner.close(); + } } impl Clone for MailboxSender { diff --git a/crates/temper-runtime/src/scheduler/sim_actor_system.rs b/crates/temper-runtime/src/scheduler/sim_actor_system.rs index 8009277c4..e54be7744 100644 --- a/crates/temper-runtime/src/scheduler/sim_actor_system.rs +++ b/crates/temper-runtime/src/scheduler/sim_actor_system.rs @@ -197,6 +197,27 @@ impl SimActorSystem { pub fn register_actor(&mut self, id: &str, mut handler: Box) { self.scheduler.register_actor(id); handler.init().expect("actor init should succeed"); + let status = handler.current_status(); + for invariant in handler.spec_invariants() { + if matches!( + &invariant.assert, + super::sim_handler::SpecAssert::Unsupported { .. } + ) { + self.recorded_invariants + .push((id.to_string(), invariant.name.clone(), false)); + self.violations.push(ActorInvariantViolation { + actor_id: id.to_string(), + action: "".to_string(), + status_before: status.clone(), + status_after: status.clone(), + description: format!( + "{}: unsupported safety assertion at actor registration", + invariant.name + ), + tick: self.clock.tick(), + }); + } + } self.actors.insert(id.to_string(), handler); self.action_counts.insert(id.to_string(), 0); } @@ -595,7 +616,11 @@ impl SimActorSystem { if let Some(handler) = self.actors.get(actor_id) { let invariants: Vec<_> = handler.spec_invariants().to_vec(); for inv in &invariants { - let triggered = inv.when.is_empty() || inv.when.iter().any(|s| s == status_after); + let triggered = matches!( + inv.assert, + super::sim_handler::SpecAssert::Unsupported { .. } + ) || inv.when.is_empty() + || inv.when.iter().any(|s| s == status_after); if !triggered { continue; } @@ -606,7 +631,6 @@ impl SimActorSystem { &inv.when, status_before, status_after, - item_count, ); let violated = !passed; @@ -650,17 +674,14 @@ fn evaluate_spec_assert( when: &[String], status_before: &str, status_after: &str, - item_count: usize, ) -> bool { use super::sim_handler::{CompareOp, SpecAssert}; match assert { + SpecAssert::RuntimeEnforced { .. } => true, + SpecAssert::Unsupported { .. } => false, SpecAssert::CounterPositive { var } => { - if var == "items" { - item_count > 0 - } else { - true // Unknown counter: not in scope for invariant checking here. - } + handler.counter_field(var).is_some_and(|value| value > 0) } SpecAssert::NoFurtherTransitions => { // Holds unless status_before was a terminal state in `when`. @@ -682,7 +703,9 @@ fn evaluate_spec_assert( } SpecAssert::NeverState { state } => status_after != state.as_str(), SpecAssert::CounterCompare { var, op, value } => { - let counter_val = if var == "items" { item_count } else { 0 }; + let Some(counter_val) = handler.counter_field(var) else { + return false; + }; match op { CompareOp::Gt => counter_val > *value, CompareOp::Gte => counter_val >= *value, @@ -694,12 +717,12 @@ fn evaluate_spec_assert( SpecAssert::BoolRequired { var, expect } => { handler.bool_field(var).unwrap_or(false) == *expect } - SpecAssert::And(parts) => parts.iter().all(|p| { - evaluate_spec_assert(p, handler, when, status_before, status_after, item_count) - }), - SpecAssert::Or(parts) => parts.iter().any(|p| { - evaluate_spec_assert(p, handler, when, status_before, status_after, item_count) - }), + SpecAssert::And(parts) => parts + .iter() + .all(|p| evaluate_spec_assert(p, handler, when, status_before, status_after)), + SpecAssert::Or(parts) => parts + .iter() + .any(|p| evaluate_spec_assert(p, handler, when, status_before, status_after)), } } diff --git a/crates/temper-runtime/src/scheduler/sim_handler.rs b/crates/temper-runtime/src/scheduler/sim_handler.rs index 9ea990d78..c0a147d3a 100644 --- a/crates/temper-runtime/src/scheduler/sim_handler.rs +++ b/crates/temper-runtime/src/scheduler/sim_handler.rs @@ -28,6 +28,11 @@ pub struct SpecInvariant { /// `And(vec![BoolRequired{"migrations_ok", true}, BoolRequired{"typecheck_ok", true}])`. #[derive(Debug, Clone)] pub enum SpecAssert { + /// Assertion enforced atomically by the shared tentative-state contract. + RuntimeEnforced { enforcement_version: u32 }, + /// An assertion outside the runtime simulation's supported typed forms. + /// This is retained explicitly and always fails instead of being omitted. + Unsupported { expression: String }, /// A counter variable must be positive (e.g., `items > 0`). CounterPositive { var: String }, /// The entity is in a terminal state — no further transitions allowed. @@ -113,6 +118,14 @@ pub trait SimActorHandler: Send { None } + /// Read a counter state variable by name for invariant evaluation. + /// + /// Returns `None` when the counter is absent, which fails closed in typed + /// counter assertions. Default: no counter state. + fn counter_field(&self, _var: &str) -> Option { + None + } + /// Custom effects (integration triggers) emitted by the last action. /// /// After each successful `handle_message()`, the simulation system calls diff --git a/crates/temper-server/src/entity_actor/actor.rs b/crates/temper-server/src/entity_actor/actor.rs index cbf46c9a7..18c215542 100644 --- a/crates/temper-server/src/entity_actor/actor.rs +++ b/crates/temper-server/src/entity_actor/actor.rs @@ -25,6 +25,7 @@ use std::collections::BTreeMap; use std::sync::{Arc, OnceLock, RwLock}; use std::time::Instant; +use sha2::{Digest, Sha256}; use temper_jit::table::{Effect, TransitionTable}; use temper_observe::wide_event; use temper_runtime::actor::{Actor, ActorContext, ActorError}; @@ -45,6 +46,22 @@ use super::types::{ MAX_ITEMS_PER_ENTITY, }; +struct ReplayOptions<'a> { + tenant: &'a str, + blob_store: Option<&'a crate::blob_store::BlobStore>, + strict_journal_read: bool, + initial_fields: &'a serde_json::Value, +} + +enum SnapshotApplication { + Applied, + DataOnly { + fields: serde_json::Map, + terminal_status: Option, + }, + Rejected, +} + fn event_budget_workspace_id(state: &EntityState) -> String { if state.entity_type == "Workspace" { return state.entity_id.clone(); @@ -125,31 +142,8 @@ impl EntityActor { entity_id: &str, table: &TransitionTable, initial_fields: &serde_json::Value, - ) -> EntityState { - let mut fields = initial_fields.clone(); - if let Some(obj) = fields.as_object_mut() { - obj.entry("Id".to_string()) - .or_insert(serde_json::Value::String(entity_id.to_string())); - obj.entry("Status".to_string()) - .or_insert(serde_json::Value::String(table.initial_state.clone())); - } - - EntityState { - entity_type: entity_type.to_string(), - entity_id: entity_id.to_string(), - status: table.initial_state.clone(), - item_count: 0, - counters: BTreeMap::new(), - booleans: BTreeMap::new(), - lists: BTreeMap::new(), - fields, - events: std::collections::VecDeque::new(), - total_event_count: 0, - events_since_snapshot: 0, - last_snapshot_sequence_nr: 0, - sequence_nr: 0, - processed_idempotency_keys: BTreeMap::new(), - } + ) -> Result { + super::effects::build_initial_entity_state(entity_type, entity_id, table, initial_fields) } /// Snapshot frequency in events. @@ -170,7 +164,16 @@ impl EntityActor { /// /// The stored snapshot is already a segment boundary, so its hot tail budget /// is reset in the payload. Lifetime sequence/count fields remain intact. - fn serialize_snapshot_state(state: &EntityState) -> Result, PersistenceError> { + fn snapshot_safety_contract(table: &TransitionTable) -> Result { + let contract = serde_json::to_vec(table) + .map_err(|e| PersistenceError::Serialization(e.to_string()))?; + Ok(format!("{:x}", Sha256::digest(contract))) + } + + fn serialize_snapshot_state( + state: &EntityState, + table: &TransitionTable, + ) -> Result, PersistenceError> { let mut value = serde_json::to_value(state) .map_err(|e| PersistenceError::Serialization(e.to_string()))?; if let Some(obj) = value.as_object_mut() { @@ -181,17 +184,80 @@ impl EntityActor { serde_json::json!(state.sequence_nr), ); } + let canonical = serde_json::to_vec(&value) + .map_err(|e| PersistenceError::Serialization(e.to_string()))?; + if let Some(obj) = value.as_object_mut() { + obj.insert( + "__temper_snapshot_contract".to_string(), + serde_json::json!(1), + ); + obj.insert( + "__temper_snapshot_sha256".to_string(), + serde_json::Value::String(format!("{:x}", Sha256::digest(canonical))), + ); + obj.insert( + "__temper_snapshot_safety_contract".to_string(), + serde_json::Value::String(Self::snapshot_safety_contract(table)?), + ); + } serde_json::to_vec(&value).map_err(|e| PersistenceError::Serialization(e.to_string())) } /// Attempt to load actor state from snapshot payload bytes. - fn apply_snapshot_bytes(state: &mut EntityState, sequence_nr: u64, bytes: &[u8]) -> bool { + /// + /// A legacy snapshot, or a valid snapshot bound to an older table, cannot + /// establish a replay boundary for model-protected state. Its ordinary + /// data fields are still durable user data, though, and may predate event + /// payload field synchronization. Preserve only those unprotected fields + /// while rebuilding status and logical state from the complete journal. + fn apply_snapshot_bytes( + state: &mut EntityState, + sequence_nr: u64, + bytes: &[u8], + table: &TransitionTable, + ) -> SnapshotApplication { let mut value = match serde_json::from_slice::(bytes) { Ok(v) => v, - Err(_) => return false, + Err(_) => return SnapshotApplication::Rejected, + }; + let (contract, checksum, safety_contract) = { + let Some(obj) = value.as_object_mut() else { + return SnapshotApplication::Rejected; + }; + ( + obj.remove("__temper_snapshot_contract"), + obj.remove("__temper_snapshot_sha256"), + obj.remove("__temper_snapshot_safety_contract"), + ) }; + let has_contract = contract.as_ref().and_then(serde_json::Value::as_u64) == Some(1); + if has_contract { + let Some(expected) = checksum.as_ref().and_then(serde_json::Value::as_str) else { + return SnapshotApplication::Rejected; + }; + let Ok(canonical) = serde_json::to_vec(&value) else { + return SnapshotApplication::Rejected; + }; + if format!("{:x}", Sha256::digest(canonical)) != expected { + return SnapshotApplication::Rejected; + } + if value.get("sequence_nr").and_then(serde_json::Value::as_u64) != Some(sequence_nr) { + return SnapshotApplication::Rejected; + } + let Ok(expected_contract) = Self::snapshot_safety_contract(table) else { + return SnapshotApplication::Rejected; + }; + if safety_contract.as_ref().and_then(serde_json::Value::as_str) + != Some(expected_contract.as_str()) + { + return Self::snapshot_data_only(&value, table, state); + } + } + if !table.model_protected_state_vars.is_empty() && !has_contract { + return Self::snapshot_data_only(&value, table, state); + } let Some(obj) = value.as_object_mut() else { - return false; + return SnapshotApplication::Rejected; }; // Snapshot intentionally excludes in-memory recent history. @@ -214,9 +280,54 @@ impl EntityActor { restored.events_since_snapshot = 0; restored.last_snapshot_sequence_nr = sequence_nr; *state = restored; - true + SnapshotApplication::Applied } - Err(_) => false, + Err(_) => SnapshotApplication::Rejected, + } + } + + fn unprotected_snapshot_fields( + snapshot: &serde_json::Value, + table: &TransitionTable, + ) -> serde_json::Map { + snapshot + .get("fields") + .and_then(serde_json::Value::as_object) + .map(|fields| { + fields + .iter() + .filter(|(name, _)| { + *name != "Id" + && *name != "Status" + && !table.model_protected_state_vars.contains(*name) + }) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + }) + .unwrap_or_default() + } + + fn snapshot_data_only( + snapshot: &serde_json::Value, + table: &TransitionTable, + expected: &EntityState, + ) -> SnapshotApplication { + let Ok(restored) = serde_json::from_value::(snapshot.clone()) else { + return SnapshotApplication::Rejected; + }; + if restored.entity_type != expected.entity_type || restored.entity_id != expected.entity_id + { + return SnapshotApplication::Rejected; + } + let terminal_status = (restored.status == "Deleted").then_some(restored.status); + SnapshotApplication::DataOnly { + fields: Self::unprotected_snapshot_fields( + &serde_json::json!({ + "fields": restored.fields, + }), + table, + ), + terminal_status, } } @@ -443,6 +554,7 @@ impl EntityActor { snapshot_queue: Option<&Arc>, persistence_id: &str, state: &mut EntityState, + table: &TransitionTable, ) -> Result<(), PersistenceError> { if state.sequence_nr == 0 { return Ok(()); @@ -466,7 +578,7 @@ impl EntityActor { return Ok(()); } - let snapshot = Self::serialize_snapshot_state(state)?; + let snapshot = Self::serialize_snapshot_state(state, table)?; if let Some(queue) = snapshot_queue { match queue.enqueue(persistence_id.to_string(), state.sequence_nr, snapshot) { SnapshotEnqueueOutcome::Enqueued @@ -502,37 +614,71 @@ impl EntityActor { store: &BoxedEventStore, backend: BackendLabel, state: &mut EntityState, - tenant: &str, - blob_store: Option<&crate::blob_store::BlobStore>, - // When true, a journal read failure PROPAGATES as an error instead of being - // swallowed ("start fresh"). The key-index backfill needs this: it must - // distinguish "entity genuinely has no events" from "could not read the - // journal", or it would watermark a type while a present entity is unkeyed - // (a wrong-absent bug). Actor hydration keeps the lenient default (false). - strict_journal_read: bool, + options: ReplayOptions<'_>, ) -> Result<(), ActorError> { + let ReplayOptions { + tenant, + blob_store, + strict_journal_read, + initial_fields, + } = options; let replay_start = Instant::now(); // determinism-ok: wall-clock for production replay duration metric only let persistence_id = format!("{tenant}:{}:{}", state.entity_type, state.entity_id); let persistence_id = persistence_id.as_str(); + let entity_type = state.entity_type.clone(); + let entity_id = state.entity_id.clone(); + *state = super::effects::build_initial_entity_state( + &entity_type, + &entity_id, + table, + initial_fields, + ) + .map_err(ActorError::custom)?; let mut from_sequence = 0; let mut loaded_snapshot = false; + let mut used_snapshot_data = false; + let mut snapshot_terminal_status = None; match store.load_snapshot(persistence_id).await { Ok(Some((snapshot_seq, snapshot_bytes))) => { - if Self::apply_snapshot_bytes(state, snapshot_seq, &snapshot_bytes) { - from_sequence = snapshot_seq; - loaded_snapshot = true; - tracing::info!( - entity = %state.entity_id, - seq = snapshot_seq, - "loaded snapshot before replay" - ); - } else { - tracing::warn!( - entity = %state.entity_id, - seq = snapshot_seq, - "failed to deserialize snapshot, falling back to full replay" - ); + match Self::apply_snapshot_bytes(state, snapshot_seq, &snapshot_bytes, table) { + SnapshotApplication::Applied => { + if let Some(error) = super::effects::runtime_invariant_failure(state, table) + { + return Err(ActorError::custom(format!( + "persisted snapshot violates runtime safety contract: {error}" + ))); + } + from_sequence = snapshot_seq; + loaded_snapshot = true; + tracing::info!( + entity = %state.entity_id, + seq = snapshot_seq, + "loaded snapshot before replay" + ); + } + SnapshotApplication::DataOnly { + fields, + terminal_status, + } => { + if let Some(current_fields) = state.fields.as_object_mut() { + current_fields.extend(fields); + } + used_snapshot_data = true; + snapshot_terminal_status = terminal_status; + tracing::warn!( + entity = %state.entity_id, + seq = snapshot_seq, + "snapshot cannot establish a safety boundary; preserving unprotected data fields and replaying the full journal" + ); + } + SnapshotApplication::Rejected => { + tracing::warn!( + entity = %state.entity_id, + seq = snapshot_seq, + "failed to deserialize snapshot, falling back to full replay" + ); + } } } Ok(None) => {} @@ -582,6 +728,12 @@ impl EntityActor { serde_json::Value::String(state.status.clone()), ); } + if let Some(error) = super::effects::runtime_invariant_failure(state, table) + { + return Err(ActorError::custom(format!( + "persisted tombstone violates runtime safety contract: {error}" + ))); + } state.push_event_bounded(tombstone); state.sequence_nr = env.sequence_nr; break; @@ -601,6 +753,22 @@ impl EntityActor { // longer knows this action/from-state, in which case // the stored `to_status` alone carries the state. let from_status = event.from_status.clone(); + super::effects::validate_declared_field_values(&event.params, table) + .map_err(|error| { + ActorError::custom(format!( + "persisted event has invalid declared field values: {error}" + )) + })?; + super::effects::sync_declared_state_vars_from_params( + state, + table, + &event.params, + ) + .map_err(|error| { + ActorError::custom(format!( + "persisted event has invalid declared state values: {error}" + )) + })?; if let Some(effects) = table.replay_effects(&state.status, &event.action) { @@ -611,7 +779,12 @@ impl EntityActor { _scheduled_actions, _spawn_requests, _schedule_at_requests, - ) = super::effects::apply_effects(state, &effects, &event.params); + ) = super::effects::apply_effects(state, &effects, &event.params) + .map_err(|error| { + ActorError::custom(format!( + "persisted event cannot be replayed safely: {error}" + )) + })?; } // Always honor the durably-stored target status. This // is safe (status is always persisted on the event) @@ -635,6 +808,13 @@ impl EntityActor { field_sync_mode, Some(&table.state_var_metadata), ); + if let Some(error) = + super::effects::runtime_invariant_failure(state, table) + { + return Err(ActorError::custom(format!( + "persisted event violates runtime safety contract: {error}" + ))); + } // Persist replayed overflow blobs so blob-ref envelopes // resolve on subsequent OData reads. Content-addressed // dedup makes this idempotent — if the original live @@ -708,6 +888,22 @@ impl EntityActor { "state restored from snapshot (no delta events)" ); } + if let Some(terminal_status) = snapshot_terminal_status { + state.status = terminal_status.clone(); + if let Some(fields) = state.fields.as_object_mut() { + fields.insert( + "Status".to_string(), + serde_json::Value::String(terminal_status), + ); + } + } + if (loaded_snapshot || used_snapshot_data || !envelopes.is_empty()) + && let Some(error) = super::effects::runtime_invariant_failure(state, table) + { + return Err(ActorError::custom(format!( + "hydrated entity violates runtime safety contract: {error}" + ))); + } } Err(e) => { if strict_journal_read { @@ -750,15 +946,19 @@ pub(crate) async fn recover_entity_state_from_store( blob_store: Option<&crate::blob_store::BlobStore>, strict_journal_read: bool, ) -> Result { - let mut state = EntityActor::build_initial_state(entity_type, entity_id, table, initial_fields); + let mut state = EntityActor::build_initial_state(entity_type, entity_id, table, initial_fields) + .map_err(ActorError::custom)?; EntityActor::replay_events( table, store, backend, &mut state, - tenant, - blob_store, - strict_journal_read, + ReplayOptions { + tenant, + blob_store, + strict_journal_read, + initial_fields, + }, ) .await?; Ok(state) @@ -778,7 +978,8 @@ impl Actor for EntityActor { &self.entity_id, &table, &self.initial_fields, - ); + ) + .map_err(ActorError::custom)?; // Replay events from Postgres to rebuild state (if persistence is configured). // Re-evaluates each event through the TransitionTable to reconstruct @@ -798,9 +999,19 @@ impl Actor for EntityActor { .await?; } + let initial_invariant_failure = (state.total_event_count == 0) + .then(|| super::effects::runtime_invariant_failure(&state, &table)) + .flatten(); + // Persist a bootstrap Created event for first-time entities so initial - // fields are durable and replayable. - if self.event_journal.is_some() && state.total_event_count == 0 { + // fields are durable and replayable. Some action-backed specs use the + // first action to establish invariants that intentionally do not hold + // in the pristine initial state. Keep that transient state out of the + // journal; the first successful action persists the first valid state. + if self.event_journal.is_some() + && state.total_event_count == 0 + && initial_invariant_failure.is_none() + { let created = EntityEvent { action: "Created".to_string(), from_status: String::new(), @@ -1069,11 +1280,15 @@ impl Actor for EntityActor { store, backend, state, - &self.tenant, - self.blob_store.as_ref(), - // Actor hydration keeps the lenient "start - // fresh on read error" behavior (unchanged). - false, + ReplayOptions { + tenant: &self.tenant, + blob_store: self.blob_store.as_ref(), + // Retry must fail closed: continuing from a + // reset or truncated replay can overwrite a + // concurrent writer with regressed state. + strict_journal_read: true, + initial_fields: &self.initial_fields, + }, ) .await?; @@ -1082,12 +1297,12 @@ impl Actor for EntityActor { // reported. Reaching further is fine (a // later writer may have appended during // our own round trip). - debug_assert!( - state.sequence_nr >= last_actual, - "POSTCONDITION: replay under-reached authoritative sequence \ - (state.sequence_nr={} < last_actual={last_actual})", - state.sequence_nr - ); + if state.sequence_nr < last_actual { + return Err(ActorError::custom(format!( + "concurrency retry replay under-reached authoritative sequence: {} < {last_actual}", + state.sequence_nr + ))); + } // Refresh baselines so postconditions hold // against the replayed state, not the @@ -1295,6 +1510,7 @@ impl Actor for EntityActor { self.snapshot_queue.as_ref(), &persistence_id, state, + &table, ) .await { @@ -1401,6 +1617,23 @@ impl Actor for EntityActor { ); } EntityMsg::GetState => { + let table = self.table.read().expect("table lock poisoned").clone(); + if state.total_event_count == 0 + && let Some(error) = super::effects::runtime_invariant_failure(state, &table) + { + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some(format!( + "entity requires an initializing action before it is readable: {error}" + )), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + } ctx.reply(EntityResponse { success: true, state: state.clone(), @@ -1411,7 +1644,84 @@ impl Actor for EntityActor { spec_governed: true, }); } + EntityMsg::Passivate => { + let table = self.table.read().expect("table lock poisoned").clone(); + let Some(store) = self.event_journal.as_ref() else { + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some("in-memory entity cannot be passivated".to_string()), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + }; + if state.sequence_nr == 0 { + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some( + "entity has no durable event and cannot be reconstructed".to_string(), + ), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + } + + let snapshot = match Self::serialize_snapshot_state(state, &table) { + Ok(snapshot) => snapshot, + Err(error) => { + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some(format!("failed to encode passivation snapshot: {error}")), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + } + }; + if let Err(error) = store + .save_snapshot(&self.persistence_id(), state.sequence_nr, &snapshot) + .await + { + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some(format!("failed to save passivation snapshot: {error}")), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + } + + ctx.reply_and_stop(EntityResponse { + success: true, + state: state.clone(), + error: None, + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + } EntityMsg::GetField { field } => { + let table = self.table.read().expect("table lock poisoned").clone(); + if state.total_event_count == 0 + && super::effects::runtime_invariant_failure(state, &table).is_some() + { + ctx.reply(serde_json::Value::Null); + return Ok(()); + } let value = state .fields .get(&field) @@ -1420,6 +1730,37 @@ impl Actor for EntityActor { ctx.reply(value); } EntityMsg::UpdateFields { fields, replace } => { + let table = self.table.read().expect("table lock poisoned").clone(); + if state.total_event_count == 0 + && let Some(error) = super::effects::runtime_invariant_failure(state, &table) + { + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some(format!( + "entity requires an initializing action before direct updates: {error}" + )), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + } + let state_before = state.clone(); + if let Err(error) = super::effects::validate_declared_field_values(&fields, &table) + { + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some(error), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + } if replace { // PUT: replace all fields (preserve Id and Status) let id = state.entity_id.clone(); @@ -1439,6 +1780,34 @@ impl Actor for EntityActor { } } } + if let Err(error) = + super::effects::sync_declared_state_vars_from_fields(state, &table) + { + *state = state_before; + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some(error), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + } + if let Some(error) = super::effects::runtime_invariant_failure(state, &table) { + *state = state_before; + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some(error), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + } ctx.reply(EntityResponse { success: true, state: state.clone(), @@ -1450,6 +1819,8 @@ impl Actor for EntityActor { }); } EntityMsg::Delete => { + let table = self.table.read().expect("table lock poisoned").clone(); + let state_before = state.clone(); let deleted = EntityEvent { action: "Deleted".to_string(), from_status: state.status.clone(), @@ -1459,12 +1830,34 @@ impl Actor for EntityActor { idempotency_key: None, }; + state.status = deleted.to_status.clone(); + if let Some(obj) = state.fields.as_object_mut() { + obj.insert( + "Status".to_string(), + serde_json::Value::String(state.status.clone()), + ); + } + if let Some(error) = super::effects::runtime_invariant_failure(state, &table) { + *state = state_before; + ctx.reply(EntityResponse { + success: false, + state: state.clone(), + error: Some(error), + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + spec_governed: true, + }); + return Ok(()); + } + if let (Some(store), Some(backend)) = (self.event_journal.as_ref(), self.event_backend) && let Err(e) = self .persist_event(store, backend, &self.persistence_id(), state, &deleted) .await { + *state = state_before; ctx.reply(EntityResponse { success: false, state: state.clone(), @@ -1476,14 +1869,6 @@ impl Actor for EntityActor { }); return Ok(()); } - - state.status = deleted.to_status.clone(); - if let Some(obj) = state.fields.as_object_mut() { - obj.insert( - "Status".to_string(), - serde_json::Value::String(state.status.clone()), - ); - } state.push_event_bounded(deleted); ctx.reply(EntityResponse { diff --git a/crates/temper-server/src/entity_actor/actor_test.rs b/crates/temper-server/src/entity_actor/actor_test.rs index 0e2f53456..6506fe13e 100644 --- a/crates/temper-server/src/entity_actor/actor_test.rs +++ b/crates/temper-server/src/entity_actor/actor_test.rs @@ -1,5 +1,5 @@ use super::*; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::time::Duration; use temper_jit::table::TransitionTable; use temper_runtime::ActorSystem; @@ -40,6 +40,24 @@ module = "scm_ingest_pack" ))) } +const DELETE_INVARIANT_IOA: &str = r#" +[automaton] +name = "Document" +states = ["Active", "Deleted"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "deletion_reason" +type = "string" +initial = "" + +[[invariant]] +name = "DeletedRequiresReason" +when = ["Deleted"] +assert = "deletion_reason != ''" +"#; + #[test] fn event_budget_workspace_id_uses_workspace_entity_id_or_field() { let workspace_state = EntityState { @@ -88,6 +106,7 @@ async fn queued_snapshot_only_advances_replay_boundary_after_write_applies() { let boxed_store = crate::storage::BoxedEventStore::from_arc(store); let snapshot_queue = SnapshotWriteQueue::start(boxed_store.clone()); let persistence_id = "default:Order:queued-snapshot-1"; + let table = TransitionTable::from_ioa_source(ORDER_IOA); let mut state = EntityState { entity_type: "Order".to_string(), entity_id: "queued-snapshot-1".to_string(), @@ -113,6 +132,7 @@ async fn queued_snapshot_only_advances_replay_boundary_after_write_applies() { Some(&snapshot_queue), persistence_id, &mut state, + &table, ) .await .expect("snapshot enqueue should succeed"); @@ -137,6 +157,7 @@ async fn queued_snapshot_only_advances_replay_boundary_after_write_applies() { Some(&snapshot_queue), persistence_id, &mut state, + &table, ) .await .expect("snapshot boundary observation should succeed"); @@ -550,6 +571,1563 @@ async fn replay_skips_schema_mismatched_events() { assert_eq!(response.state.total_event_count, 1); } +/// Durable history that violates the current runtime safety contract must not +/// hydrate into a live entity. This covers both full journal replay and the +/// same validation applied after loading a snapshot plus its event tail. +#[cfg(feature = "sim")] +#[tokio::test] +async fn replay_rejects_persisted_runtime_invariant_violation() { + use temper_runtime::persistence::EventStore; + use temper_store_sim::SimEventStore; + + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Goal" +states = ["Draft", "Active"] +initial = "Draft" + +[[state]] +name = "goal" +type = "string" +initial = "" + +[[action]] +name = "Create" +kind = "input" +from = ["Draft"] +to = "Active" +params = ["goal"] + +[[invariant]] +name = "active_goal_required" +when = ["Active"] +assert = "goal != ''" +"#, + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Goal:goal-replay-1"; + let envelope = PersistenceEnvelope { + sequence_nr: 0, + event_type: "Create".to_string(), + payload: serde_json::json!({ + "action": "Create", + "from_status": "Draft", + "to_status": "Active", + "timestamp": "2024-01-01T00:00:00Z", + "params": {"goal": ""} + }), + metadata: EventMetadata { + event_id: sim_uuid(), + causation_id: sim_uuid(), + correlation_id: sim_uuid(), + timestamp: sim_now(), + actor_id: pid.to_string(), + }, + }; + store.append(pid, 0, &[envelope]).await.unwrap(); + let boxed_store = crate::storage::BoxedEventStore::from_arc(store); + + let error = recover_entity_state_from_store( + "default", + "Goal", + "goal-replay-1", + &table, + &boxed_store, + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect_err("invalid durable history must fail hydration"); + + assert!( + error + .to_string() + .contains("persisted event violates runtime safety contract"), + "unexpected replay error: {error}" + ); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn declared_counter_action_param_survives_replay() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "File" +states = ["Created", "Ready"] +initial = "Created" + +[[state]] +name = "size_bytes" +type = "counter" +initial = "0" + +[[action]] +name = "StreamUpdated" +kind = "input" +from = ["Created", "Ready"] +to = "Ready" +params = ["size_bytes"] +"#, + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:File:declared-counter-replay"; + store + .append( + pid, + 0, + &[PersistenceEnvelope { + sequence_nr: 0, + event_type: "StreamUpdated".to_string(), + payload: serde_json::json!({ + "action": "StreamUpdated", + "from_status": "Created", + "to_status": "Ready", + "timestamp": "2024-01-01T00:00:00Z", + "params": {"size_bytes": 42} + }), + metadata: EventMetadata { + event_id: sim_uuid(), + causation_id: sim_uuid(), + correlation_id: sim_uuid(), + timestamp: sim_now(), + actor_id: pid.to_string(), + }, + }], + ) + .await + .expect("append declared counter event"); + let boxed_store = crate::storage::BoxedEventStore::from_arc(store); + + let recovered = recover_entity_state_from_store( + "default", + "File", + "declared-counter-replay", + &table, + &boxed_store, + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect("declared counter event must replay"); + + assert_eq!(recovered.counters["size_bytes"], 42); + assert_eq!(recovered.fields["size_bytes"], 42); + assert_eq!(recovered.status, "Ready"); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn modeled_effect_can_consume_protected_action_param_during_replay() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Cart" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "items" +type = "counter" +initial = "1" + +[[action]] +name = "Update" +kind = "input" +from = ["Active"] +to = "Active" +params = ["items"] +effect = [{ type = "set_counter_from_param", var = "items", param = "items" }] + +[[invariant]] +name = "HasItems" +when = ["Active"] +assert = "items > 0" +"#, + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Cart:protected-replay"; + store + .append( + pid, + 0, + &[PersistenceEnvelope { + sequence_nr: 0, + event_type: "Update".to_string(), + payload: serde_json::json!({ + "action": "Update", + "from_status": "Active", + "to_status": "Active", + "timestamp": "2024-01-01T00:00:00Z", + "params": {"items": 2} + }), + metadata: EventMetadata { + event_id: sim_uuid(), + causation_id: sim_uuid(), + correlation_id: sim_uuid(), + timestamp: sim_now(), + actor_id: pid.to_string(), + }, + }], + ) + .await + .expect("append protected event"); + let boxed_store = crate::storage::BoxedEventStore::from_arc(store); + + let recovered = recover_entity_state_from_store( + "default", + "Cart", + "protected-replay", + &table, + &boxed_store, + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect("modeled effect must replay protected state"); + + assert_eq!(recovered.counters["items"], 2); + assert_eq!(recovered.fields["items"], 2); +} + +#[cfg(all(feature = "observe", feature = "sim"))] +#[tokio::test] +async fn parameter_counter_effect_cannot_escape_verified_limit_live_or_on_replay() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let source = r#" +[automaton] +name = "Budget" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "budget" +type = "counter" +initial = "0" + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["budget"] +effect = [{ type = "set_counter_from_param", var = "budget", param = "budget" }] + +[[invariant]] +name = "BudgetLimit" +when = ["Active"] +assert = "budget <= 10" +"#; + + let cascade = temper_verify::VerificationCascade::from_ioa(source) + .with_sim_seeds(1) + .with_prop_test_cases(4) + .run(); + assert!( + cascade.all_passed, + "runtime enforcement must preserve cascade admission: {:?}", + cascade.errors + ); + + let table = TransitionTable::from_ioa_source(source); + let mut live_state = crate::entity_actor::effects::build_initial_entity_state( + "Budget", + "budget-live", + &table, + &serde_json::json!({}), + ) + .expect("build live state"); + let live_result = crate::entity_actor::effects::process_action( + &mut live_state, + &table, + "SetBudget", + &serde_json::json!({"budget": 100}), + ); + assert!( + !live_result.success, + "unsafe live mutation must be rejected" + ); + assert_eq!(live_state.counters["budget"], 0); + + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Budget:budget-replay"; + store + .append( + pid, + 0, + &[PersistenceEnvelope { + sequence_nr: 0, + event_type: "SetBudget".to_string(), + payload: serde_json::json!({ + "action": "SetBudget", + "from_status": "Active", + "to_status": "Active", + "timestamp": "2024-01-01T00:00:00Z", + "params": {"budget": 100} + }), + metadata: EventMetadata { + event_id: sim_uuid(), + causation_id: sim_uuid(), + correlation_id: sim_uuid(), + timestamp: sim_now(), + actor_id: pid.to_string(), + }, + }], + ) + .await + .expect("append unsafe persisted event"); + + let error = recover_entity_state_from_store( + "default", + "Budget", + "budget-replay", + &table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect_err("unsafe persisted mutation must fail replay"); + assert!( + error.to_string().contains("BudgetLimit"), + "unexpected replay error: {error}" + ); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn malformed_counter_effect_params_fail_durable_replay() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Budget" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "budget" +type = "counter" +initial = "0" + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["amount"] +effect = [{ type = "set_counter_from_param", var = "budget", param = "amount" }] + +[[action]] +name = "IncreaseBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["amount"] +effect = [{ type = "increment", var = "budget", amount = "amount" }] + +[[action]] +name = "DecreaseBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["amount"] +effect = [{ type = "decrement", var = "budget", amount = "amount" }] +"#, + ); + + for (index, action) in ["SetBudget", "IncreaseBudget", "DecreaseBudget"] + .into_iter() + .enumerate() + { + let store = Arc::new(SimEventStore::no_faults(213 + index as u64)); + let entity_id = format!("malformed-replay-{index}"); + let pid = format!("default:Budget:{entity_id}"); + store + .append( + &pid, + 0, + &[PersistenceEnvelope { + sequence_nr: 0, + event_type: action.to_string(), + payload: serde_json::json!({ + "action": action, + "from_status": "Active", + "to_status": "Active", + "timestamp": "2024-01-01T00:00:00Z", + "params": {"amount": "bad"} + }), + metadata: EventMetadata { + event_id: sim_uuid(), + causation_id: sim_uuid(), + correlation_id: sim_uuid(), + timestamp: sim_now(), + actor_id: pid.clone(), + }, + }], + ) + .await + .expect("append malformed persisted event"); + + let error = recover_entity_state_from_store( + "default", + "Budget", + &entity_id, + &table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect_err("malformed persisted counter parameter must fail replay"); + assert!( + error.to_string().contains( + "persisted event cannot be replayed safely: runtime counter parameter 'amount' must be a usize" + ), + "unexpected replay error for {action}: {error}" + ); + } +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn hydration_rejects_invalid_snapshot_before_healing_tail_event() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let source = r#" +[automaton] +name = "Goal" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "goal" +type = "string" +initial = "" + +[[action]] +name = "Update" +kind = "input" +from = ["Active"] +to = "Active" +params = ["goal"] + +[[invariant]] +name = "GoalRequired" +when = ["Active"] +assert = "goal != ''" +"#; + let table = TransitionTable::from_ioa_source(source); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Goal:goal-snapshot-1"; + let event = |action: &str, params: serde_json::Value| PersistenceEnvelope { + sequence_nr: 0, + event_type: action.to_string(), + payload: serde_json::json!({ + "action": action, + "from_status": "Active", + "to_status": "Active", + "timestamp": "2024-01-01T00:00:00Z", + "params": params + }), + metadata: EventMetadata { + event_id: sim_uuid(), + causation_id: sim_uuid(), + correlation_id: sim_uuid(), + timestamp: sim_now(), + actor_id: pid.to_string(), + }, + }; + store + .append(pid, 0, &[event("Created", serde_json::json!({}))]) + .await + .unwrap(); + store + .append( + pid, + 1, + &[event("Update", serde_json::json!({"goal": "healed"}))], + ) + .await + .unwrap(); + + let mut invalid_snapshot = crate::entity_actor::effects::build_initial_entity_state( + "Goal", + "goal-snapshot-1", + &table, + &serde_json::json!({}), + ) + .expect("build snapshot state"); + invalid_snapshot.sequence_nr = 1; + invalid_snapshot.total_event_count = 1; + store + .save_snapshot( + pid, + 1, + &serde_json::to_vec(&invalid_snapshot).expect("serialize invalid snapshot"), + ) + .await + .unwrap(); + + let error = recover_entity_state_from_store( + "default", + "Goal", + "goal-snapshot-1", + &table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect_err("invalid snapshot must fail before a healing tail event"); + assert!( + error + .to_string() + .contains("persisted snapshot violates runtime safety contract"), + "unexpected snapshot error: {error}" + ); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn model_protected_snapshot_is_rebuilt_from_authoritative_history() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Payment" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "payment_captured" +type = "bool" +initial = "true" + +[[invariant]] +name = "PaymentCaptured" +when = ["Active"] +assert = "payment_captured" +"#, + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Payment:protected-snapshot"; + let mut corrupted = crate::entity_actor::effects::build_initial_entity_state( + "Payment", + "protected-snapshot", + &table, + &serde_json::json!({}), + ) + .expect("build snapshot state"); + corrupted.booleans.insert("payment_captured".into(), false); + corrupted.fields["payment_captured"] = serde_json::json!(false); + corrupted.fields["Receipt"] = serde_json::json!("durable-user-data"); + corrupted.sequence_nr = 1; + corrupted.total_event_count = 1; + store + .save_snapshot( + pid, + 1, + &serde_json::to_vec(&corrupted).expect("serialize corrupted snapshot"), + ) + .await + .expect("save corrupted snapshot"); + + let recovered = recover_entity_state_from_store( + "default", + "Payment", + "protected-snapshot", + &table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect("protected state must rebuild without trusting snapshot values"); + + assert!(recovered.booleans["payment_captured"]); + assert_eq!(recovered.fields["payment_captured"], true); + assert_eq!(recovered.fields["Receipt"], "durable-user-data"); + assert_eq!(recovered.sequence_nr, 0); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn malformed_legacy_snapshot_cannot_inject_data_or_terminal_status() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Payment" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "payment_captured" +type = "bool" +initial = "true" + +[[invariant]] +name = "PaymentCaptured" +when = ["Active"] +assert = "payment_captured" +"#, + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Payment:malformed-legacy-snapshot"; + store + .save_snapshot( + pid, + 1, + &serde_json::to_vec(&serde_json::json!({ + "status": "Deleted", + "fields": {"Receipt": "injected"} + })) + .expect("serialize malformed snapshot"), + ) + .await + .expect("save malformed snapshot"); + + let recovered = recover_entity_state_from_store( + "default", + "Payment", + "malformed-legacy-snapshot", + &table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect("malformed snapshot must be ignored in favor of canonical state"); + + assert_eq!(recovered.status, "Active"); + assert!(recovered.fields.get("Receipt").is_none()); + assert!(recovered.booleans["payment_captured"]); + assert_eq!(recovered.sequence_nr, 0); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn contracted_protected_snapshot_remains_a_bounded_replay_boundary() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Payment" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "payment_captured" +type = "bool" +initial = "true" + +[[invariant]] +name = "PaymentCaptured" +when = ["Active"] +assert = "payment_captured" +"#, + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Payment:protected-snapshot-boundary"; + let mut snapshot = crate::entity_actor::effects::build_initial_entity_state( + "Payment", + "protected-snapshot-boundary", + &table, + &serde_json::json!({}), + ) + .expect("build snapshot state"); + snapshot.sequence_nr = (MAX_EVENTS_SINCE_SNAPSHOT as u64) + 1; + snapshot.total_event_count = MAX_EVENTS_SINCE_SNAPSHOT + 1; + let snapshot_bytes = + EntityActor::serialize_snapshot_state(&snapshot, &table).expect("snapshot"); + store + .save_snapshot(pid, snapshot.sequence_nr, &snapshot_bytes) + .await + .expect("save contracted snapshot"); + + let recovered = recover_entity_state_from_store( + "default", + "Payment", + "protected-snapshot-boundary", + &table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect("contracted snapshot must avoid unbounded full replay"); + + assert_eq!(recovered.sequence_nr, snapshot.sequence_nr); + assert!(recovered.booleans["payment_captured"]); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn contracted_snapshot_sequence_mismatch_falls_back_to_full_replay() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Payment" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "payment_captured" +type = "bool" +initial = "true" + +[[invariant]] +name = "PaymentCaptured" +when = ["Active"] +assert = "payment_captured" +"#, + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Payment:snapshot-sequence-mismatch"; + let mut snapshot = crate::entity_actor::effects::build_initial_entity_state( + "Payment", + "snapshot-sequence-mismatch", + &table, + &serde_json::json!({}), + ) + .expect("build snapshot state"); + snapshot.sequence_nr = 5; + snapshot.total_event_count = 5; + let snapshot_bytes = + EntityActor::serialize_snapshot_state(&snapshot, &table).expect("snapshot"); + store + .save_snapshot(pid, 6, &snapshot_bytes) + .await + .expect("save mismatched snapshot row"); + + let recovered = recover_entity_state_from_store( + "default", + "Payment", + "snapshot-sequence-mismatch", + &table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect("mismatched boundary must fall back to full history"); + + assert_eq!(recovered.sequence_nr, 0); + assert!(recovered.booleans["payment_captured"]); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn snapshot_from_weaker_safety_contract_is_not_a_replay_boundary() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let old_table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Payment" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "payment_captured" +type = "bool" +initial = "true" +"#, + ); + let new_table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Payment" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "payment_captured" +type = "bool" +initial = "true" + +[[invariant]] +name = "PaymentCaptured" +when = ["Active"] +assert = "payment_captured" +"#, + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Payment:weaker-snapshot-contract"; + let mut old_snapshot = crate::entity_actor::effects::build_initial_entity_state( + "Payment", + "weaker-snapshot-contract", + &old_table, + &serde_json::json!({"payment_captured": false}), + ) + .expect("build old snapshot state"); + assert!(!old_snapshot.booleans["payment_captured"]); + old_snapshot.sequence_nr = 5; + old_snapshot.total_event_count = 5; + let snapshot_bytes = + EntityActor::serialize_snapshot_state(&old_snapshot, &old_table).expect("old snapshot"); + store + .save_snapshot(pid, 5, &snapshot_bytes) + .await + .expect("save old contracted snapshot"); + + let recovered = recover_entity_state_from_store( + "default", + "Payment", + "weaker-snapshot-contract", + &new_table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect("contract mismatch must replay authoritative history"); + + assert_eq!(recovered.sequence_nr, 0); + assert!(recovered.booleans["payment_captured"]); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn full_replay_resets_materialized_protected_state_before_effects() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = TransitionTable::from_ioa_source( + r#" +[automaton] +name = "Counter" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "count" +type = "counter" +initial = "1" + +[[action]] +name = "Increment" +kind = "input" +from = ["Active"] +to = "Active" +effect = [{ type = "increment", var = "count" }] + +[[invariant]] +name = "PositiveCount" +when = ["Active"] +assert = "count > 0" +"#, + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Counter:retry-reset"; + store + .append( + pid, + 0, + &[PersistenceEnvelope { + sequence_nr: 0, + event_type: "Increment".to_string(), + payload: serde_json::json!({ + "action": "Increment", + "from_status": "Active", + "to_status": "Active", + "timestamp": "2024-01-01T00:00:00Z", + "params": {} + }), + metadata: EventMetadata { + event_id: sim_uuid(), + causation_id: sim_uuid(), + correlation_id: sim_uuid(), + timestamp: sim_now(), + actor_id: pid.to_string(), + }, + }], + ) + .await + .expect("append increment"); + let mut materialized = crate::entity_actor::effects::build_initial_entity_state( + "Counter", + "retry-reset", + &table, + &serde_json::json!({}), + ) + .expect("build materialized state"); + materialized.counters.insert("count".into(), 2); + materialized.fields["count"] = serde_json::json!(2); + + EntityActor::replay_events( + &table, + &crate::storage::BoxedEventStore::from_arc(store.clone()), + crate::storage::BackendLabel::Sim, + &mut materialized, + ReplayOptions { + tenant: "default", + blob_store: None, + strict_journal_read: true, + initial_fields: &serde_json::json!({}), + }, + ) + .await + .expect("full replay"); + + assert_eq!(materialized.counters["count"], 2); + assert_eq!(materialized.fields["count"], 2); + + store.fail_next_reads(pid, 1); + let error = EntityActor::replay_events( + &table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &mut materialized, + ReplayOptions { + tenant: "default", + blob_store: None, + strict_journal_read: true, + initial_fields: &serde_json::json!({}), + }, + ) + .await + .expect_err("strict retry replay must fail closed on journal read failure"); + assert!( + error + .to_string() + .contains("failed to read events for replay") + ); + assert_eq!(materialized.counters["count"], 1); +} + +/// An action-backed entity may use its first action to establish invariants +/// that intentionally do not hold in the spec's pristine initial state. That +/// transient state must remain unreadable and must never become durable. +#[cfg(feature = "sim")] +#[tokio::test] +async fn creation_requires_initializing_action_before_persistence() { + use temper_runtime::persistence::EventStore; + use temper_store_sim::SimEventStore; + + let source = r#" +[automaton] +name = "ToolCall" +states = ["Pending"] +initial = "Pending" +allow_indefinite_states = ["Pending"] + +[[state]] +name = "agent_id" +type = "string" +initial = "" + +[[action]] +name = "Initialize" +kind = "input" +from = ["Pending"] +params = ["agent_id"] + +[[invariant]] +name = "RequiresAgentId" +when = ["Pending"] +assert = "agent_id != ''" +"#; + let table = Arc::new(RwLock::new(TransitionTable::from_ioa_source(source))); + let table_snapshot = table.read().unwrap().clone(); + let initial_state = crate::entity_actor::effects::build_initial_entity_state( + "ToolCall", + "tc-invalid", + &table_snapshot, + &serde_json::json!({}), + ) + .expect("build typed initial state"); + let diagnostic = + crate::entity_actor::effects::runtime_invariant_failure(&initial_state, &table_snapshot) + .expect("empty agent_id must identify the blocking invariant"); + assert!(diagnostic.contains("RequiresAgentId")); + let store = Arc::new(SimEventStore::no_faults(213)); + let boxed_store = crate::storage::BoxedEventStore::from_arc(store.clone()); + let system = ActorSystem::new("sim-runtime-invariant-create"); + let actor = EntityActor::with_persistence( + "ToolCall", + "tc-invalid", + table, + serde_json::json!({}), + boxed_store, + crate::storage::BackendLabel::Sim, + ); + let actor_ref = system.spawn(actor, "tc-invalid"); + + let response: EntityResponse = actor_ref + .ask(EntityMsg::GetState, Duration::from_secs(5)) + .await + .expect("uninitialized actor must remain available for initialization"); + assert!( + !response.success, + "uninitialized state must not be readable" + ); + assert!( + store + .read_events("default:ToolCall:tc-invalid", 0) + .await + .expect("read invalid entity journal") + .is_empty(), + "invalid initial state must not persist a bootstrap event" + ); + + let response: EntityResponse = actor_ref + .ask( + EntityMsg::Action { + name: "Initialize".to_string(), + params: serde_json::json!({"agent_id": "agent-1"}), + cross_entity_booleans: std::collections::BTreeMap::new(), + idempotency_key: None, + }, + Duration::from_secs(5), + ) + .await + .expect("initializing action must be accepted"); + assert!( + response.success, + "initializing action must establish safety" + ); + assert_eq!(response.state.fields["agent_id"], "agent-1"); + let events = store + .read_events("default:ToolCall:tc-invalid", 0) + .await + .expect("read initialized entity journal"); + assert_eq!(events.len(), 1, "only the valid initialization is durable"); + let event: crate::entity_actor::EntityEvent = + serde_json::from_value(events[0].payload.clone()).expect("decode initialization event"); + assert_eq!(event.action, "Initialize"); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn direct_field_update_cannot_bypass_runtime_invariant() { + let source = r#" +[automaton] +name = "Goal" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "goal" +type = "string" +initial = "" + +[[invariant]] +name = "GoalRequired" +when = ["Active"] +assert = "goal != ''" +"#; + let table = Arc::new(RwLock::new(TransitionTable::from_ioa_source(source))); + let system = ActorSystem::new("sim-runtime-invariant-field-update"); + let actor = EntityActor::new( + "Goal", + "goal-update-1", + table, + serde_json::json!({"goal": "ship safely"}), + ); + let actor_ref = system.spawn(actor, "goal-update-1"); + + let rejected: EntityResponse = actor_ref + .ask( + EntityMsg::UpdateFields { + fields: serde_json::json!({"goal": ""}), + replace: false, + }, + Duration::from_secs(5), + ) + .await + .expect("field update reply"); + assert!(!rejected.success); + assert_eq!(rejected.state.fields["goal"], "ship safely"); + assert!( + rejected + .error + .as_deref() + .is_some_and(|error| error.contains("GoalRequired")) + ); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn direct_counter_field_update_is_synchronized_and_rejected_atomically() { + let source = r#" +[automaton] +name = "Workspace" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "used_bytes" +type = "counter" +initial = "0" + +[[state]] +name = "quota_limit" +type = "counter" +initial = "10" + +[[invariant]] +name = "WithinQuota" +when = ["Active"] +assert = "used_bytes <= quota_limit" +"#; + let table = Arc::new(RwLock::new(TransitionTable::from_ioa_source(source))); + let system = ActorSystem::new("sim-runtime-invariant-counter-field-update"); + let actor = EntityActor::new("Workspace", "ws-update-1", table, serde_json::json!({})); + let actor_ref = system.spawn(actor, "ws-update-1"); + + let rejected: EntityResponse = actor_ref + .ask( + EntityMsg::UpdateFields { + fields: serde_json::json!({"used_bytes": 11}), + replace: false, + }, + Duration::from_secs(5), + ) + .await + .expect("counter field update reply"); + assert!(!rejected.success); + assert_eq!(rejected.state.counters.get("used_bytes"), Some(&0)); + assert_eq!(rejected.state.counters.get("quota_limit"), Some(&10)); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn model_protected_action_param_does_not_implicitly_mutate_state() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let source = r#" +[automaton] +name = "Payment" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "payment_captured" +type = "bool" +initial = "true" + +[[action]] +name = "Update" +kind = "input" +from = ["Active"] +to = "Active" +params = ["payment_captured"] + +[[invariant]] +name = "PaymentCaptured" +when = ["Active"] +assert = "payment_captured" +"#; + let table = Arc::new(RwLock::new(TransitionTable::from_ioa_source(source))); + let store = Arc::new(SimEventStore::no_faults(213)); + let boxed_store = crate::storage::BoxedEventStore::from_arc(store.clone()); + let system = ActorSystem::new("sim-model-protected-action"); + let actor = EntityActor::with_persistence( + "Payment", + "payment-1", + table, + serde_json::json!({"payment_captured": false}), + boxed_store, + crate::storage::BackendLabel::Sim, + ); + let actor_ref = system.spawn(actor, "payment-1"); + let before: EntityResponse = actor_ref + .ask(EntityMsg::GetState, Duration::from_secs(5)) + .await + .expect("initial state"); + assert!(before.state.booleans["payment_captured"]); + assert_eq!(before.state.fields["payment_captured"], true); + let initial_events = store + .read_events("default:Payment:payment-1", 0) + .await + .expect("initial journal"); + + let response: EntityResponse = actor_ref + .ask( + EntityMsg::Action { + name: "Update".to_string(), + params: serde_json::json!({"payment_captured": false}), + cross_entity_booleans: BTreeMap::new(), + idempotency_key: None, + }, + Duration::from_secs(5), + ) + .await + .expect("action reply"); + + assert!(response.success); + assert_eq!(response.state.status, before.state.status); + assert_eq!(response.state.counters, before.state.counters); + assert_eq!(response.state.booleans, before.state.booleans); + assert_eq!(response.state.fields, before.state.fields); + let after_events = store + .read_events("default:Payment:payment-1", 0) + .await + .expect("journal after rejection"); + assert_eq!(after_events.len(), initial_events.len() + 1); + + let replay_table = TransitionTable::from_ioa_source(source); + let recovered = recover_entity_state_from_store( + "default", + "Payment", + "payment-1", + &replay_table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect("protected no-op parameter must replay"); + assert!(recovered.booleans["payment_captured"]); + assert_eq!(recovered.fields["payment_captured"], true); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn model_protected_patch_rolls_back_bool_and_literal_counter_state() { + let source = r#" +[automaton] +name = "Payment" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "items" +type = "counter" +initial = "1" + +[[state]] +name = "payment_captured" +type = "bool" +initial = "true" + +[[invariant]] +name = "ReadyToSettle" +when = ["Active"] +assert = "items > 0 && payment_captured" +"#; + let table = Arc::new(RwLock::new(TransitionTable::from_ioa_source(source))); + let system = ActorSystem::new("sim-model-protected-patch"); + let actor = EntityActor::new("Payment", "payment-2", table, serde_json::json!({})); + let actor_ref = system.spawn(actor, "payment-2"); + let before: EntityResponse = actor_ref + .ask(EntityMsg::GetState, Duration::from_secs(5)) + .await + .expect("initial state"); + let before_state = serde_json::to_value(&before.state).expect("serialize initial state"); + + for fields in [ + serde_json::json!({"payment_captured": false}), + serde_json::json!({"items": 0}), + ] { + let rejected: EntityResponse = actor_ref + .ask( + EntityMsg::UpdateFields { + fields, + replace: false, + }, + Duration::from_secs(5), + ) + .await + .expect("patch reply"); + assert!(!rejected.success); + assert_eq!( + serde_json::to_value(&rejected.state).expect("serialize rejected state"), + before_state + ); + } +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn guard_only_model_state_cannot_make_proved_unsafe_action_reachable() { + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let source = r#" +[automaton] +name = "GuardClosure" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" + +[[state]] +name = "unlock" +type = "bool" +initial = "false" + +[[action]] +name = "BreakSafety" +kind = "input" +from = ["Active"] +to = "Active" +guard = [{ type = "is_true", var = "unlock" }] +effect = [{ type = "set_bool", var = "safe", value = "false" }] + +[[invariant]] +name = "AlwaysSafe" +when = ["Active"] +assert = "safe" +"#; + let table = Arc::new(RwLock::new(TransitionTable::from_ioa_source(source))); + assert_eq!( + table.read().unwrap().model_protected_state_vars, + BTreeSet::from(["safe".to_string(), "unlock".to_string()]) + ); + let store = Arc::new(SimEventStore::no_faults(213)); + let system = ActorSystem::new("sim-model-guard-closure"); + let actor = EntityActor::with_persistence( + "GuardClosure", + "guard-closure-1", + table, + serde_json::json!({"unlock": true}), + crate::storage::BoxedEventStore::from_arc(store.clone()), + crate::storage::BackendLabel::Sim, + ); + let actor_ref = system.spawn(actor, "guard-closure-1"); + + let created: EntityResponse = actor_ref + .ask(EntityMsg::GetState, Duration::from_secs(5)) + .await + .expect("created state"); + assert!(created.state.booleans["safe"]); + assert!(!created.state.booleans["unlock"]); + + let patch: EntityResponse = actor_ref + .ask( + EntityMsg::UpdateFields { + fields: serde_json::json!({"unlock": true}), + replace: false, + }, + Duration::from_secs(5), + ) + .await + .expect("patch reply"); + assert!(!patch.success); + assert!(!patch.state.booleans["unlock"]); + + let action: EntityResponse = actor_ref + .ask( + EntityMsg::Action { + name: "BreakSafety".to_string(), + params: serde_json::json!({}), + cross_entity_booleans: BTreeMap::new(), + idempotency_key: None, + }, + Duration::from_secs(5), + ) + .await + .expect("guarded action reply"); + assert!(!action.success); + assert!(action.state.booleans["safe"]); + assert!(!action.state.booleans["unlock"]); + + let recovered = recover_entity_state_from_store( + "default", + "GuardClosure", + "guard-closure-1", + &TransitionTable::from_ioa_source(source), + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect("proved state must replay inside the model state space"); + assert!(recovered.booleans["safe"]); + assert!(!recovered.booleans["unlock"]); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn delete_rolls_back_before_persisting_runtime_invariant_violation() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = Arc::new(RwLock::new(TransitionTable::from_ioa_source( + DELETE_INVARIANT_IOA, + ))); + let store = Arc::new(SimEventStore::no_faults(213)); + let system = ActorSystem::new("sim-runtime-invariant-delete"); + let actor = EntityActor::with_persistence( + "Document", + "doc-delete-1", + table, + serde_json::json!({}), + crate::storage::BoxedEventStore::from_arc(store.clone()), + crate::storage::BackendLabel::Sim, + ); + let actor_ref = system.spawn(actor, "doc-delete-1"); + + let rejected: EntityResponse = actor_ref + .ask(EntityMsg::Delete, Duration::from_secs(5)) + .await + .expect("delete reply"); + assert!(!rejected.success); + assert_eq!(rejected.state.status, "Active"); + assert!( + rejected + .error + .as_deref() + .is_some_and(|error| error.contains("DeletedRequiresReason")) + ); + let events = store + .read_events("default:Document:doc-delete-1", 0) + .await + .expect("read document journal"); + assert_eq!(events.len(), 1, "only bootstrap Created may be durable"); + assert_eq!(events[0].event_type, "Created"); +} + +#[cfg(feature = "sim")] +#[tokio::test] +async fn replay_rejects_invalid_persisted_tombstone() { + use temper_runtime::persistence::EventStore; + use temper_runtime::scheduler::install_deterministic_context; + use temper_store_sim::SimEventStore; + + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = TransitionTable::from_ioa_source(DELETE_INVARIANT_IOA); + let store = Arc::new(SimEventStore::no_faults(213)); + let pid = "default:Document:doc-tombstone-1"; + let tombstone = PersistenceEnvelope { + sequence_nr: 0, + event_type: "Deleted".to_string(), + payload: serde_json::json!({ + "action": "Deleted", + "from_status": "Active", + "to_status": "Deleted", + "timestamp": "2024-01-01T00:00:00Z", + "params": {} + }), + metadata: EventMetadata { + event_id: sim_uuid(), + causation_id: sim_uuid(), + correlation_id: sim_uuid(), + timestamp: sim_now(), + actor_id: pid.to_string(), + }, + }; + store.append(pid, 0, &[tombstone]).await.unwrap(); + + let error = recover_entity_state_from_store( + "default", + "Document", + "doc-tombstone-1", + &table, + &crate::storage::BoxedEventStore::from_arc(store), + crate::storage::BackendLabel::Sim, + &serde_json::json!({}), + None, + true, + ) + .await + .expect_err("invalid tombstone must fail hydration"); + assert!( + error + .to_string() + .contains("persisted tombstone violates runtime safety contract"), + "unexpected tombstone error: {error}" + ); +} + /// A committed cross-entity-guarded transition must survive replay. /// /// Regression: `File.StreamUpdated` carries a `cross_entity_state` guard on the diff --git a/crates/temper-server/src/entity_actor/effects.rs b/crates/temper-server/src/entity_actor/effects.rs index 1dd7aac5f..fa6894ab5 100644 --- a/crates/temper-server/src/entity_actor/effects.rs +++ b/crates/temper-server/src/entity_actor/effects.rs @@ -58,6 +58,14 @@ pub struct ScheduleAtRequest { pub field: String, } +/// Effects that require follow-up processing after state mutation completes. +pub type AppliedEffects = ( + Vec, + Vec, + Vec, + Vec, +); + /// Maximum cross-entity lookups per transition (TigerStyle budget). /// /// Rich contracts may legitimately verify several sibling entities in one @@ -66,6 +74,290 @@ pub const MAX_CROSS_ENTITY_LOOKUPS: usize = 16; /// Maximum entity spawns per transition (TigerStyle budget). pub const MAX_SPAWNS_PER_TRANSITION: usize = 8; +#[derive(Clone, Copy, PartialEq, Eq)] +enum RuntimeFieldKind { + Counter, + String, +} + +fn runtime_field_kind(table: &TransitionTable, name: &str) -> Option { + table + .runtime_invariants + .iter() + .find_map(|invariant| runtime_assert_field_kind(&invariant.assertion, name)) +} + +fn runtime_assert_field_kind( + assertion: &temper_spec::automaton::RuntimeAssert, + name: &str, +) -> Option { + use temper_spec::automaton::RuntimeAssert; + match assertion { + RuntimeAssert::StringNonEmpty { var } if var == name => Some(RuntimeFieldKind::String), + RuntimeAssert::CounterPositive { var } | RuntimeAssert::CounterCompare { var, .. } + if var == name => + { + Some(RuntimeFieldKind::Counter) + } + RuntimeAssert::CounterVarCompare { left, right, .. } if left == name || right == name => { + Some(RuntimeFieldKind::Counter) + } + RuntimeAssert::And(parts) | RuntimeAssert::Or(parts) => parts + .iter() + .find_map(|part| runtime_assert_field_kind(part, name)), + _ => None, + } +} + +/// Build the canonical initial entity state shared by production, simulation, +/// and the durable data-only creation path. +pub(crate) fn build_initial_entity_state( + entity_type: &str, + entity_id: &str, + table: &TransitionTable, + initial_fields: &serde_json::Value, +) -> Result { + use temper_jit::table::types::StateVarInitialValue; + + let mut counters = std::collections::BTreeMap::new(); + let mut booleans = std::collections::BTreeMap::new(); + let mut declared_fields = serde_json::Map::new(); + for (name, initial) in &table.state_var_initials { + match initial { + StateVarInitialValue::Counter(value) => { + counters.insert(name.clone(), *value); + declared_fields.insert(name.clone(), serde_json::json!(value)); + } + StateVarInitialValue::Bool(value) => { + booleans.insert(name.clone(), *value); + declared_fields.insert(name.clone(), serde_json::Value::Bool(*value)); + } + StateVarInitialValue::String(value) => { + declared_fields.insert(name.clone(), serde_json::Value::String(value.clone())); + } + } + } + + let fields = if let Some(initial) = initial_fields.as_object() { + for (name, value) in initial { + match table.state_var_initials.get(name) { + Some(StateVarInitialValue::Counter(_)) => { + match value.as_u64().and_then(|value| usize::try_from(value).ok()) { + Some(value) => { + if !table.model_protected_state_vars.contains(name) { + counters.insert(name.clone(), value); + } + } + None if runtime_field_kind(table, name) + == Some(RuntimeFieldKind::Counter) => + { + return Err(format!("runtime counter field '{name}' must be a usize")); + } + None => {} + } + } + Some(StateVarInitialValue::Bool(_)) => { + let value = value + .as_bool() + .ok_or_else(|| format!("declared bool field '{name}' must be boolean"))?; + if !table.model_protected_state_vars.contains(name) { + booleans.insert(name.clone(), value); + } + } + Some(StateVarInitialValue::String(_)) => { + if value.as_str().is_none() + && runtime_field_kind(table, name) == Some(RuntimeFieldKind::String) + { + return Err(format!("runtime string field '{name}' must be a string")); + } + } + _ => {} + } + if table.model_protected_state_vars.contains(name) { + // Model-proved counters and booleans start from the declared + // initial state. Caller fields remain available to a modeled + // Create action, but cannot rewrite logical state directly. + continue; + } + declared_fields.insert(name.clone(), value.clone()); + } + declared_fields + .entry("Id".to_string()) + .or_insert_with(|| serde_json::Value::String(entity_id.to_string())); + declared_fields + .entry("Status".to_string()) + .or_insert_with(|| serde_json::Value::String(table.initial_state.clone())); + serde_json::Value::Object(declared_fields) + } else { + return Err("initial entity fields must be an object".to_string()); + }; + + let item_count = counters.get("items").copied().unwrap_or(0); + Ok(EntityState { + entity_type: entity_type.to_string(), + entity_id: entity_id.to_string(), + status: table.initial_state.clone(), + item_count, + counters, + booleans, + lists: std::collections::BTreeMap::new(), + fields, + events: std::collections::VecDeque::new(), + total_event_count: 0, + events_since_snapshot: 0, + last_snapshot_sequence_nr: 0, + sequence_nr: 0, + processed_idempotency_keys: std::collections::BTreeMap::new(), + }) +} + +/// Synchronize direct field mutations into declared counter/boolean state. +pub(crate) fn sync_declared_state_vars_from_fields( + state: &mut EntityState, + table: &TransitionTable, +) -> Result<(), String> { + let fields = state + .fields + .as_object() + .ok_or_else(|| "entity fields must be an object".to_string())?; + sync_declared_state_vars( + &mut state.counters, + &mut state.booleans, + &mut state.item_count, + table, + fields, + DeclaredStateSyncMode::DirectFields, + )?; + if let Some(fields) = state.fields.as_object_mut() { + for name in &table.model_protected_state_vars { + if let Some(value) = state.counters.get(name) { + fields.insert(name.clone(), serde_json::json!(value)); + } else if let Some(value) = state.booleans.get(name) { + fields.insert(name.clone(), serde_json::json!(value)); + } + } + } + Ok(()) +} + +/// Synchronize action parameters into declared counter/boolean state before +/// applying effects, so effect mutations remain authoritative. +pub(crate) fn sync_declared_state_vars_from_params( + state: &mut EntityState, + table: &TransitionTable, + params: &serde_json::Value, +) -> Result<(), String> { + let params = params + .as_object() + .ok_or_else(|| "action params must be an object".to_string())?; + sync_declared_state_vars( + &mut state.counters, + &mut state.booleans, + &mut state.item_count, + table, + params, + DeclaredStateSyncMode::ActionParams, + ) +} + +#[derive(Clone, Copy)] +enum DeclaredStateSyncMode { + ActionParams, + DirectFields, +} + +fn sync_declared_state_vars( + counters: &mut std::collections::BTreeMap, + booleans: &mut std::collections::BTreeMap, + item_count: &mut usize, + table: &TransitionTable, + values: &serde_json::Map, + mode: DeclaredStateSyncMode, +) -> Result<(), String> { + use temper_jit::table::types::StateVarInitialValue; + + for (name, initial) in &table.state_var_initials { + let Some(value) = values.get(name) else { + continue; + }; + if table.model_protected_state_vars.contains(name) { + if matches!(mode, DeclaredStateSyncMode::ActionParams) { + // Preserve the parameter as an input to explicit modeled + // effects, but never treat its name as an implicit assignment. + continue; + } + let changed = match initial { + StateVarInitialValue::Counter(_) => { + value.as_u64().and_then(|value| usize::try_from(value).ok()) + != counters.get(name).copied() + } + StateVarInitialValue::Bool(_) => value.as_bool() != booleans.get(name).copied(), + StateVarInitialValue::String(_) => false, + }; + if changed { + return Err(format!( + "declared state field '{name}' is controlled by verified transition effects" + )); + } + continue; + } + match initial { + StateVarInitialValue::Counter(_) => { + let counter = value.as_u64().and_then(|value| usize::try_from(value).ok()); + let Some(counter) = counter else { + if runtime_field_kind(table, name) == Some(RuntimeFieldKind::Counter) { + return Err(format!("runtime counter field '{name}' must be a usize")); + } + continue; + }; + counters.insert(name.clone(), counter); + if name == "items" { + *item_count = counter; + } + } + StateVarInitialValue::Bool(_) => { + let boolean = value + .as_bool() + .ok_or_else(|| format!("declared bool field '{name}' must be boolean"))?; + booleans.insert(name.clone(), boolean); + } + StateVarInitialValue::String(_) => {} + } + } + Ok(()) +} + +/// Validate caller-supplied values for declared runtime state variables. +/// +/// Internal overflow blob envelopes are allowed in stored state, but callers +/// cannot submit them in creation, action, replay, or direct-update payloads. +pub(crate) fn validate_declared_field_values( + values: &serde_json::Value, + table: &TransitionTable, +) -> Result<(), String> { + let values = values + .as_object() + .ok_or_else(|| "declared field payload must be an object".to_string())?; + for (name, value) in values { + match runtime_field_kind(table, name) { + Some(RuntimeFieldKind::Counter) => { + if value + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .is_none() + { + return Err(format!("runtime counter field '{name}' must be a usize")); + } + } + Some(RuntimeFieldKind::String) if value.as_str().is_none() => { + return Err(format!("runtime string field '{name}' must be a string")); + } + _ => {} + } + } + Ok(()) +} + /// Build an [`EvalContext`] from current entity state. /// /// This is the single source of truth for context construction. All code paths @@ -223,6 +515,7 @@ pub fn process_action_with_xref_and_field_mode( cross_entity_booleans: &std::collections::BTreeMap, field_sync_mode: FieldSyncMode, ) -> ProcessResult { + let state_before_action = state.clone(); if state.events_since_snapshot >= MAX_EVENTS_SINCE_SNAPSHOT { return ProcessResult { success: false, @@ -260,8 +553,47 @@ pub fn process_action_with_xref_and_field_mode( let effective_params = normalize_ref_action_params(state, action, params); let params = effective_params.as_ref(); + if let Err(error) = validate_declared_field_values(params, table) { + return ProcessResult { + success: false, + event: None, + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + overflow_blobs: vec![], + error: Some(error), + }; + } + + if let Err(error) = sync_declared_state_vars_from_params(state, table, params) { + *state = state_before_action; + return ProcessResult { + success: false, + event: None, + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + overflow_blobs: vec![], + error: Some(error), + }; + } + let (custom_effects, scheduled_actions, spawn_requests, schedule_at_requests) = - apply_effects(state, &transition_result.effects, params); + match apply_effects(state, &transition_result.effects, params) { + Ok(result) => result, + Err(error) => { + *state = state_before_action; + return ProcessResult { + success: false, + event: None, + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + overflow_blobs: vec![], + error: Some(error), + }; + } + }; apply_new_state_fallback(state, &from_status, &to_status); let overflow_blobs = sync_fields_with_metadata( state, @@ -270,6 +602,19 @@ pub fn process_action_with_xref_and_field_mode( Some(&table.state_var_metadata), ); + if let Some(error) = runtime_invariant_failure(state, table) { + *state = state_before_action; + return ProcessResult { + success: false, + event: None, + custom_effects: vec![], + scheduled_actions: vec![], + spawn_requests: vec![], + overflow_blobs: vec![], + error: Some(error), + }; + } + // Resolve deferred schedule_at requests now that fields are synced let mut all_scheduled = scheduled_actions; all_scheduled.extend(resolve_schedule_at_requests(state, &schedule_at_requests)); @@ -325,6 +670,42 @@ pub fn process_action_with_xref_and_field_mode( } } +pub(crate) fn runtime_invariant_failure( + state: &EntityState, + table: &TransitionTable, +) -> Option { + let Some(fields) = state.fields.as_object() else { + return (!table.runtime_invariants.is_empty()).then(|| { + "Action rejected because runtime safety state fields are not an object".to_string() + }); + }; + table.runtime_invariants.iter().find_map(|invariant| { + if invariant.enforcement_version + != temper_spec::automaton::RUNTIME_INVARIANT_ENFORCEMENT_VERSION + { + return Some(format!( + "Unsupported runtime safety contract version {} for invariant '{}'", + invariant.enforcement_version, invariant.name + )); + } + let active = invariant.when.is_empty() || invariant.when.contains(&state.status); + (active + && !temper_spec::automaton::evaluate_runtime_assert( + &invariant.assertion, + &state.status, + &state.counters, + &state.booleans, + fields, + )) + .then(|| { + format!( + "Action rejected by runtime safety invariant '{}': {:?}", + invariant.name, invariant.assertion + ) + }) + }) +} + fn validate_ref_action_contract( state: &EntityState, action: &str, @@ -443,12 +824,7 @@ pub fn apply_effects( state: &mut EntityState, effects: &[Effect], params: &serde_json::Value, -) -> ( - Vec, - Vec, - Vec, - Vec, -) { +) -> Result { let mut custom_effects = Vec::new(); let mut scheduled_actions = Vec::new(); let mut spawn_requests = Vec::new(); @@ -460,8 +836,16 @@ pub fn apply_effects( state.status = s.clone(); } Effect::IncrementItems => { - state.item_count += 1; - *state.counters.entry("items".to_string()).or_default() += 1; + let item_count = state + .item_count + .checked_add(1) + .ok_or_else(|| "Counter 'items' overflowed its runtime range".to_string())?; + let counter = state.counters.get("items").copied().unwrap_or_default(); + let counter = counter + .checked_add(1) + .ok_or_else(|| "Counter 'items' overflowed its runtime range".to_string())?; + state.item_count = item_count; + state.counters.insert("items".to_string(), counter); } Effect::DecrementItems => { state.item_count = state.item_count.saturating_sub(1); @@ -469,18 +853,30 @@ pub fn apply_effects( *c = c.saturating_sub(1); } Effect::IncrementCounter(var) => { - *state.counters.entry(var.clone()).or_default() += 1; + let value = state.counters.get(var).copied().unwrap_or_default(); + let value = value + .checked_add(1) + .ok_or_else(|| format!("Counter '{var}' overflowed its runtime range"))?; // Keep legacy item_count in sync. if var == "items" { - state.item_count += 1; + state.item_count = state.item_count.checked_add(1).ok_or_else(|| { + "Counter 'items' overflowed its runtime range".to_string() + })?; } + state.counters.insert(var.clone(), value); } Effect::IncrementCounterByParam { var, param } => { - let delta = counter_delta_from_params(params, param); - *state.counters.entry(var.clone()).or_default() += delta; + let delta = counter_delta_from_params(params, param)?; + let value = state.counters.get(var).copied().unwrap_or_default(); + let value = value + .checked_add(delta) + .ok_or_else(|| format!("Counter '{var}' overflowed its runtime range"))?; if var == "items" { - state.item_count += delta; + state.item_count = state.item_count.checked_add(delta).ok_or_else(|| { + "Counter 'items' overflowed its runtime range".to_string() + })?; } + state.counters.insert(var.clone(), value); } Effect::DecrementCounter(var) => { let c = state.counters.entry(var.clone()).or_default(); @@ -490,7 +886,7 @@ pub fn apply_effects( } } Effect::DecrementCounterByParam { var, param } => { - let delta = counter_delta_from_params(params, param); + let delta = counter_delta_from_params(params, param)?; let c = state.counters.entry(var.clone()).or_default(); *c = c.saturating_sub(delta); if var == "items" { @@ -498,27 +894,10 @@ pub fn apply_effects( } } Effect::SetCounterFromParam { var, param } => { - let parsed = params - .get(param) - .and_then(|v| { - v.as_u64() - .or_else(|| v.as_i64().and_then(|n| u64::try_from(n).ok())) - }) - .and_then(|n| usize::try_from(n).ok()); - match parsed { - Some(value) => { - state.counters.insert(var.clone(), value); - if var == "items" { - state.item_count = value; - } - } - None => tracing::warn!( - entity_type = %state.entity_type, - entity_id = %state.entity_id, - counter = %var, - param = %param, - "set_counter_from_param skipped because param was missing or not a non-negative integer" - ), + let value = counter_value_from_params(params, param)?; + state.counters.insert(var.clone(), value); + if var == "items" { + state.item_count = value; } } Effect::SetBool { var, value } => { @@ -649,23 +1028,33 @@ pub fn apply_effects( } } - ( + Ok(( custom_effects, scheduled_actions, spawn_requests, schedule_at_requests, - ) + )) } -fn counter_delta_from_params(params: &serde_json::Value, param: &str) -> usize { +fn counter_value_from_params(params: &serde_json::Value, param: &str) -> Result { + params + .get(param) + .and_then(serde_json::Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| format!("runtime counter parameter '{param}' must be a usize")) +} + +fn counter_delta_from_params(params: &serde_json::Value, param: &str) -> Result { params .get(param) .and_then(|value| match value { - serde_json::Value::Number(number) => number.as_u64().map(|v| v as usize), + serde_json::Value::Number(number) => number + .as_u64() + .and_then(|value| usize::try_from(value).ok()), serde_json::Value::String(text) => text.parse::().ok(), _ => None, }) - .unwrap_or(0) + .ok_or_else(|| format!("runtime counter parameter '{param}' must be a usize")) } /// Resolve deferred `schedule_at` requests into [`ScheduledAction`]s. @@ -944,6 +1333,193 @@ effect = [{ type = "schedule", action = "Refresh", delay_seconds = 2700 }] assert_eq!(result.scheduled_actions[0].delay_seconds, 2700); } + #[test] + fn counter_overflow_rejects_action_and_rolls_back_all_tentative_effects() { + let _guard = temper_runtime::scheduler::install_deterministic_context(213); + let spec = r#" +[automaton] +name = "Meter" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "used" +type = "counter" +initial = "0" + +[[action]] +name = "Consume" +from = ["Active"] +to = "Active" +effect = [{ type = "increment", var = "used" }] +"#; + let table = temper_jit::table::TransitionTable::from_ioa_source(spec); + let mut state = build_initial_entity_state("Meter", "m-1", &table, &serde_json::json!({})) + .expect("build initial meter state"); + state.counters.insert("used".into(), usize::MAX); + let before = serde_json::to_value(&state).unwrap(); + + let result = process_action(&mut state, &table, "Consume", &serde_json::json!({})); + + assert!(!result.success); + assert!( + result + .error + .as_deref() + .is_some_and(|error| error.contains("overflowed its runtime range")) + ); + assert_eq!( + serde_json::to_value(&state).unwrap(), + before, + "overflow must not leave partial state" + ); + assert!(result.event.is_none(), "rejected overflow is never durable"); + } + + #[test] + fn invalid_declared_initial_values_fail_instead_of_falling_back_to_defaults() { + let spec = r#" +[automaton] +name = "Meter" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "used" +type = "counter" +initial = "7" + +[[state]] +name = "label" +type = "string" +initial = "ready" + +[[state]] +name = "limit" +type = "counter" +initial = "10" + +[[invariant]] +name = "WithinLimit" +when = ["Active"] +assert = "used <= limit" + +[[invariant]] +name = "LabelRequired" +when = ["Active"] +assert = "label != ''" +"#; + let table = temper_jit::table::TransitionTable::from_ioa_source(spec); + + let counter_error = build_initial_entity_state( + "Meter", + "m-invalid-counter", + &table, + &serde_json::json!({"used": "not-a-counter"}), + ) + .expect_err("invalid counter must not retain the declared default"); + assert!(counter_error.contains("must be a usize")); + + let forged_blob_error = build_initial_entity_state( + "Meter", + "m-invalid-string", + &table, + &serde_json::json!({ + "label": { + "__temper_blob_ref": "fake", + "__temper_blob_encoding": "json" + } + }), + ) + .expect_err("caller-supplied blob envelope must not satisfy a declared string"); + assert!(forged_blob_error.contains("must be a string")); + } + + #[test] + fn oversized_nonempty_string_blob_ref_preserves_runtime_invariant_semantics() { + let _guard = temper_runtime::scheduler::install_deterministic_context(213); + let spec = r#" +[automaton] +name = "Document" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "body" +type = "string" +initial = "ready" +overflow_inline_max_bytes = 8 + +[[invariant]] +name = "BodyRequired" +when = ["Active"] +assert = "body != ''" + +[[action]] +name = "Update" +from = ["Active"] +to = "Active" +"#; + let table = temper_jit::table::TransitionTable::from_ioa_source(spec); + let mut state = + build_initial_entity_state("Document", "d-1", &table, &serde_json::json!({})) + .expect("build initial document state"); + + let forged = process_action_with_xref_and_field_mode( + &mut state, + &table, + "Update", + &serde_json::json!({ + "body": { + "__temper_blob_ref": "fake", + "__temper_blob_encoding": "json" + } + }), + &std::collections::BTreeMap::new(), + FieldSyncMode::BlobRefs { + default_inline_max: DEFAULT_FIELD_INLINE_MAX, + }, + ); + assert!( + !forged.success, + "caller-supplied blob envelopes are not strings" + ); + assert!( + forged + .error + .as_deref() + .is_some_and(|error| error.contains("must be a string")) + ); + + let result = process_action_with_xref_and_field_mode( + &mut state, + &table, + "Update", + &serde_json::json!({"body": "a durable nonempty body"}), + &std::collections::BTreeMap::new(), + FieldSyncMode::BlobRefs { + default_inline_max: DEFAULT_FIELD_INLINE_MAX, + }, + ); + + assert!( + result.success, + "blob projection must preserve logical non-emptiness" + ); + assert_eq!(result.overflow_blobs.len(), 1); + assert!( + state + .fields + .get("body") + .and_then(serde_json::Value::as_object) + .is_some_and(|object| object.contains_key("__temper_blob_ref")) + ); + assert!(runtime_invariant_failure(&state, &table).is_none()); + } + /// Build a minimal `EntityState` for guard-error rendering tests. fn test_state(entity_type: &str, status: &str) -> EntityState { EntityState { @@ -1058,7 +1634,7 @@ to = "Closed" }; let (custom, scheduled, _spawns, _schedule_at) = - apply_effects(&mut state, &effects, &serde_json::json!({})); + apply_effects(&mut state, &effects, &serde_json::json!({})).unwrap(); assert!(custom.is_empty()); assert_eq!(scheduled.len(), 1); @@ -1104,7 +1680,8 @@ to = "Closed" "size_bytes": "30", "released_bytes": 7, }), - ); + ) + .unwrap(); assert_eq!(state.counters.get("used_bytes"), Some(&33)); } @@ -1513,6 +2090,118 @@ effect = [{ type = "set_counter_from_param", var = "size_bytes", param = "payloa ); } + #[test] + fn counter_effect_param_rejects_missing_or_malformed_values_atomically() { + let spec = r#" +[automaton] +name = "Upload" +states = ["Pending", "Ready"] +initial = "Pending" + +[[state]] +name = "size_bytes" +type = "counter" +initial = "0" + +[[action]] +name = "Set" +from = ["Pending"] +to = "Ready" +params = ["payload_size"] +effect = [{ type = "set_counter_from_param", var = "size_bytes", param = "payload_size" }] + +[[action]] +name = "Increment" +from = ["Pending"] +to = "Ready" +params = ["payload_size"] +effect = [{ type = "increment", var = "size_bytes", amount = "payload_size" }] + +[[action]] +name = "Decrement" +from = ["Pending"] +to = "Ready" +params = ["payload_size"] +effect = [{ type = "decrement", var = "size_bytes", amount = "payload_size" }] +"#; + + let table = TransitionTable::from_ioa_source(spec); + for (action, params) in ["Set", "Increment", "Decrement"] + .into_iter() + .flat_map(|action| { + [ + serde_json::json!({}), + serde_json::json!({ "payload_size": "bad" }), + serde_json::json!({ "payload_size": -1 }), + ] + .into_iter() + .map(move |params| (action, params)) + }) + { + let mut state = EntityState { + entity_type: "Upload".into(), + entity_id: "upload-1".into(), + status: "Pending".into(), + item_count: 0, + counters: std::collections::BTreeMap::new(), + booleans: std::collections::BTreeMap::new(), + lists: std::collections::BTreeMap::new(), + fields: serde_json::json!({}), + events: std::collections::VecDeque::new(), + total_event_count: 0, + events_since_snapshot: 0, + last_snapshot_sequence_nr: 0, + sequence_nr: 0, + processed_idempotency_keys: std::collections::BTreeMap::new(), + }; + + let result = process_action(&mut state, &table, action, ¶ms); + + assert!( + !result.success, + "{action} must reject invalid params: {params}" + ); + assert_eq!(state.status, "Pending"); + assert_eq!(state.counters.get("size_bytes"), None); + assert!(state.events.is_empty()); + assert_eq!( + result.error.as_deref(), + Some("runtime counter parameter 'payload_size' must be a usize") + ); + } + + for action in ["Increment", "Decrement"] { + let mut state = EntityState { + entity_type: "Upload".into(), + entity_id: "upload-1".into(), + status: "Pending".into(), + item_count: 0, + counters: std::collections::BTreeMap::new(), + booleans: std::collections::BTreeMap::new(), + lists: std::collections::BTreeMap::new(), + fields: serde_json::json!({}), + events: std::collections::VecDeque::new(), + total_event_count: 0, + events_since_snapshot: 0, + last_snapshot_sequence_nr: 0, + sequence_nr: 0, + processed_idempotency_keys: std::collections::BTreeMap::new(), + }; + + let result = process_action( + &mut state, + &table, + action, + &serde_json::json!({ "payload_size": "4096" }), + ); + + assert!( + result.success, + "{action} must retain numeric-string support" + ); + } + } + #[test] fn test_spawn_with_copy_fields() { let _guard = temper_runtime::scheduler::install_deterministic_context(42); diff --git a/crates/temper-server/src/entity_actor/sim_handler.rs b/crates/temper-server/src/entity_actor/sim_handler.rs index 7082baa71..d4d1f1c10 100644 --- a/crates/temper-server/src/entity_actor/sim_handler.rs +++ b/crates/temper-server/src/entity_actor/sim_handler.rs @@ -6,6 +6,7 @@ //! `EntityActor::handle()`: same `evaluate()` call, same effect application, //! same event recording. No async, no persistence, no telemetry. +use std::collections::BTreeSet; use std::sync::Arc; use temper_jit::table::{EvalContext, TransitionTable}; @@ -23,6 +24,7 @@ use super::types::EntityState; pub struct EntityActorHandler { table: Arc, state: EntityState, + initial_fields: serde_json::Value, invariants: Vec, /// Custom effects from the last successful action (integration triggers). last_custom_effects: Vec, @@ -40,32 +42,30 @@ impl EntityActorHandler { let entity_type = entity_type.into(); let entity_id = entity_id.into(); - let state = EntityState { - entity_type, - entity_id, - status: table.initial_state.clone(), - item_count: 0, - counters: std::collections::BTreeMap::new(), - booleans: std::collections::BTreeMap::new(), - lists: std::collections::BTreeMap::new(), - fields: serde_json::json!({}), - events: std::collections::VecDeque::new(), - total_event_count: 0, - events_since_snapshot: 0, - last_snapshot_sequence_nr: 0, - sequence_nr: 0, - processed_idempotency_keys: std::collections::BTreeMap::new(), - }; + let state = super::effects::build_initial_entity_state( + &entity_type, + &entity_id, + &table, + &serde_json::json!({}), + ) + .expect("compiled initial state values must be well typed"); Self { table, state, + initial_fields: serde_json::json!({}), invariants: Vec::new(), last_custom_effects: Vec::new(), last_scheduled_actions: Vec::new(), } } + /// Supply the same creation fields used to initialize a production actor. + pub fn with_initial_fields(mut self, initial_fields: serde_json::Value) -> Self { + self.initial_fields = initial_fields; + self + } + /// Build an [`EvalContext`] from the current entity state. fn eval_context(&self) -> EvalContext { super::effects::build_eval_context(&self.state) @@ -78,23 +78,50 @@ impl EntityActorHandler { pub fn with_ioa_invariants(mut self, ioa_toml: &str) -> Self { let automaton = temper_spec::automaton::parse_automaton(ioa_toml) .expect("failed to parse I/O Automaton TOML for invariants"); - let declared_bools: std::collections::BTreeSet<_> = automaton - .state - .iter() - .filter(|state| is_declared_bool(state)) - .map(|state| state.name.clone()) - .collect(); + let declared_bools = declared_names(&automaton.state, "bool"); + let declared_counters = declared_names(&automaton.state, "counter"); + let declared_strings = declared_names(&automaton.state, "string"); + let declared_statuses: BTreeSet<_> = automaton.automaton.states.iter().cloned().collect(); + let compiled_runtime = temper_spec::automaton::compile_runtime_invariants(&automaton); self.invariants = automaton .invariants .iter() - .filter_map(|inv| { - let assert_kind = parse_assert_expr(&inv.assert, &declared_bools)?; - Some(SpecInvariant { + .map(|inv| { + let references_declared_states = inv + .when + .iter() + .all(|state| declared_statuses.contains(state)); + let mut assert_kind = if references_declared_states { + parse_assert_expr( + &inv.assert, + &declared_bools, + &declared_counters, + &declared_strings, + &declared_statuses, + ) + } else { + None + } + .unwrap_or_else(|| SpecAssert::Unsupported { + expression: inv.assert.clone(), + }); + if matches!(assert_kind, SpecAssert::RuntimeEnforced { .. }) { + let attached = compiled_runtime + .iter() + .find(|runtime| runtime.name == inv.name) + .is_some_and(|runtime| self.table.runtime_invariants.contains(runtime)); + if !attached { + assert_kind = SpecAssert::Unsupported { + expression: inv.assert.clone(), + }; + } + } + SpecInvariant { name: inv.name.clone(), when: inv.when.clone(), assert: assert_kind, - }) + } }) .collect(); @@ -109,24 +136,32 @@ impl EntityActorHandler { /// that the framework cannot check automatically. fn parse_assert_expr( expr: &str, - declared_bools: &std::collections::BTreeSet, + declared_bools: &BTreeSet, + declared_counters: &BTreeSet, + declared_strings: &BTreeSet, + declared_statuses: &BTreeSet, ) -> Option { use temper_spec::automaton::parse_assert_expr as parse; - translate_parsed(parse(expr)?, declared_bools) + let parsed = parse(expr)?; + if !parsed.is_supported_safety_assertion( + declared_bools, + declared_counters, + declared_strings, + declared_statuses, + ) { + return None; + } + translate_parsed(parsed) } -fn translate_parsed( - parsed: temper_spec::automaton::ParsedAssert, - declared_bools: &std::collections::BTreeSet, -) -> Option { +fn translate_parsed(parsed: temper_spec::automaton::ParsedAssert) -> Option { use temper_spec::automaton::{AssertCompareOp, ParsedAssert}; match parsed { + ParsedAssert::Always => Some(SpecAssert::And(Vec::new())), ParsedAssert::CounterPositive { var } => Some(SpecAssert::CounterPositive { var }), ParsedAssert::NoFurtherTransitions => Some(SpecAssert::NoFurtherTransitions), - ParsedAssert::OrderingConstraint { before, after } => { - Some(SpecAssert::OrderingConstraint { before, after }) - } + ParsedAssert::OrderingConstraint { .. } => None, ParsedAssert::NeverState { state } => Some(SpecAssert::NeverState { state }), ParsedAssert::CounterCompare { var, op, value } => { let runtime_op = match op { @@ -142,48 +177,41 @@ fn translate_parsed( value, }) } - ParsedAssert::BoolRequired { var, expect } => declared_bools - .contains(&var) - .then_some(SpecAssert::BoolRequired { var, expect }), + ParsedAssert::CounterVarCompare { .. } | ParsedAssert::StringNonEmpty { .. } => { + Some(SpecAssert::RuntimeEnforced { + enforcement_version: temper_spec::automaton::RUNTIME_INVARIANT_ENFORCEMENT_VERSION, + }) + } + ParsedAssert::BoolRequired { var, expect } => { + Some(SpecAssert::BoolRequired { var, expect }) + } ParsedAssert::And(parts) => { - let mapped: Option> = parts - .into_iter() - .map(|part| translate_parsed(part, declared_bools)) - .collect(); + let mapped: Option> = parts.into_iter().map(translate_parsed).collect(); mapped.map(SpecAssert::And) } ParsedAssert::Or(parts) => { - let mapped: Option> = parts - .into_iter() - .map(|part| translate_parsed(part, declared_bools)) - .collect(); + let mapped: Option> = parts.into_iter().map(translate_parsed).collect(); mapped.map(SpecAssert::Or) } } } -fn is_declared_bool(state: &StateVar) -> bool { - state.var_type == "bool" +fn declared_names(states: &[StateVar], var_type: &str) -> BTreeSet { + states + .iter() + .filter(|state| state.var_type == var_type) + .map(|state| state.name.clone()) + .collect() } impl SimActorHandler for EntityActorHandler { fn init(&mut self) -> Result { - // Reset to initial state - self.state.status = self.table.initial_state.clone(); - self.state.item_count = 0; - self.state.counters.clear(); - self.state.booleans.clear(); - self.state.lists.clear(); - self.state.events.clear(); - self.state.total_event_count = 0; - self.state.events_since_snapshot = 0; - self.state.last_snapshot_sequence_nr = 0; - self.state.sequence_nr = 0; - self.state.fields = serde_json::json!({ - "Id": self.state.entity_id, - "Status": self.state.status, - }); - + self.state = super::effects::build_initial_entity_state( + &self.state.entity_type, + &self.state.entity_id, + &self.table, + &self.initial_fields, + )?; Ok(serde_json::to_value(&self.state).unwrap_or_default()) } @@ -252,6 +280,10 @@ impl SimActorHandler for EntityActorHandler { self.state.booleans.get(var).copied() } + fn counter_field(&self, var: &str) -> Option { + self.state.counters.get(var).copied() + } + fn pending_callbacks(&self) -> Vec { self.last_custom_effects.clone() } @@ -372,10 +404,7 @@ mod tests { names.contains(&"CancelledIsFinal"), "should have CancelledIsFinal, got: {names:?}" ); - assert!( - !names.contains(&"ShipRequiresPayment"), - "undeclared bool invariants should be skipped in simulation, got: {names:?}" - ); + assert!(names.contains(&"ShipRequiresPayment")); } #[test] @@ -386,3 +415,7 @@ mod tests { assert!(handler.spec_invariants().is_empty()); } } + +#[cfg(test)] +#[path = "sim_handler/runtime_invariant_tests.rs"] +mod runtime_invariant_tests; diff --git a/crates/temper-server/src/entity_actor/sim_handler/runtime_invariant_tests.rs b/crates/temper-server/src/entity_actor/sim_handler/runtime_invariant_tests.rs new file mode 100644 index 000000000..c35944f12 --- /dev/null +++ b/crates/temper-server/src/entity_actor/sim_handler/runtime_invariant_tests.rs @@ -0,0 +1,269 @@ +use super::*; +use temper_runtime::scheduler::{ + FaultConfig, SimActorSystem, SimActorSystemConfig, install_deterministic_context, +}; + +const ORDER_IOA: &str = include_str!("../../../../../test-fixtures/specs/order.ioa.toml"); + +#[test] +fn unsupported_invariant_is_retained_as_deterministic_simulation_violation() { + let unsupported_ioa = r#" +[automaton] +name = "Order" +states = ["Draft"] +initial = "Draft" +allow_indefinite_states = ["Draft"] + +[[invariant]] +name = "UnsupportedQuota" +when = ["Draft"] +assert = "used_bytes ** quota_limit" +"#; + let config = SimActorSystemConfig { + seed: 213, + max_ticks: 0, + faults: FaultConfig::none(), + ..SimActorSystemConfig::default() + }; + let mut simulation = SimActorSystem::new(config); + let handler = EntityActorHandler::new( + "Order", + "o1", + Arc::new(TransitionTable::from_ioa_source(ORDER_IOA)), + ) + .with_ioa_invariants(unsupported_ioa); + simulation.register_actor("o1", Box::new(handler)); + + let result = simulation.run_random(); + + assert!(!result.all_invariants_held); + assert_eq!(result.transitions, 0); + assert!(simulation.has_violations()); + assert!( + simulation + .violations() + .iter() + .any(|violation| violation.description.contains("UnsupportedQuota")) + ); +} + +#[test] +fn undeclared_counter_and_trigger_state_fail_closed_in_direct_simulation() { + let cases = [ + ( + "UndeclaredCounter", + r#" +[automaton] +name = "Order" +states = ["Draft"] +initial = "Draft" +allow_indefinite_states = ["Draft"] + +[[invariant]] +name = "UndeclaredCounter" +when = ["Draft"] +assert = "ghost > 0" +"#, + ), + ( + "UndeclaredTrigger", + r#" +[automaton] +name = "Order" +states = ["Draft"] +initial = "Draft" +allow_indefinite_states = ["Draft"] + +[[invariant]] +name = "UndeclaredTrigger" +when = ["Ghost"] +assert = "true" +"#, + ), + ]; + + for (name, ioa) in cases { + let mut simulation = SimActorSystem::new(SimActorSystemConfig { + seed: 213, + faults: FaultConfig::none(), + ..SimActorSystemConfig::default() + }); + let handler = EntityActorHandler::new( + "Order", + "o1", + Arc::new(TransitionTable::from_ioa_source(ORDER_IOA)), + ) + .with_ioa_invariants(ioa); + simulation.register_actor("o1", Box::new(handler)); + + simulation.step("o1", "AddItem", "{}").expect("step order"); + + assert!( + simulation + .violations() + .iter() + .any(|violation| violation.description.contains(name)), + "{name} must be retained as an unsupported invariant violation" + ); + } +} + +#[test] +fn runtime_classification_requires_matching_table_enforcement_artifact() { + let ioa = r#" +[automaton] +name = "Order" +states = ["Draft"] +initial = "Draft" +allow_indefinite_states = ["Draft"] + +[[state]] +name = "goal" +type = "string" +initial = "" + +[[invariant]] +name = "GoalRequired" +when = ["Draft"] +assert = "goal != ''" +"#; + let mut simulation = SimActorSystem::new(SimActorSystemConfig { + seed: 213, + faults: FaultConfig::none(), + ..SimActorSystemConfig::default() + }); + let handler = EntityActorHandler::new( + "Order", + "o1", + Arc::new(TransitionTable::from_ioa_source(ORDER_IOA)), + ) + .with_ioa_invariants(ioa); + simulation.register_actor("o1", Box::new(handler)); + + simulation.step("o1", "AddItem", "{}").expect("step order"); + + assert!(simulation.has_violations()); + assert!( + simulation + .violations() + .iter() + .any(|violation| violation.description.contains("GoalRequired")) + ); +} + +#[test] +fn runtime_string_invariant_rejects_and_rolls_back_tentative_state() { + let ioa = r#" +[automaton] +name = "Agent" +states = ["Idle", "Assigned"] +initial = "Idle" + +[[state]] +name = "goal" +type = "string" +initial = "" + +[[action]] +name = "Assign" +from = ["Idle"] +to = "Assigned" +params = ["goal"] + +[[invariant]] +name = "AssignedRequiresGoal" +when = ["Assigned"] +assert = "goal != ''" +"#; + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = Arc::new(TransitionTable::from_ioa_source(ioa)); + let mut handler = EntityActorHandler::new("Agent", "a1", table); + handler.init().expect("initialize actor"); + + let rejected = handler.handle_message("Assign", r#"{"goal":""}"#); + assert!(rejected.is_err()); + assert_eq!(handler.current_status(), "Idle"); + assert_eq!(handler.event_count(), 0); + + handler + .handle_message("Assign", r#"{"goal":"ship safely"}"#) + .expect("non-empty goal satisfies runtime invariant"); + assert_eq!(handler.current_status(), "Assigned"); + assert_eq!(handler.event_count(), 1); +} + +#[test] +fn simulation_initialization_matches_production_runtime_invariant_gate() { + let ioa = r#" +[automaton] +name = "ToolCall" +states = ["Pending"] +initial = "Pending" +allow_indefinite_states = ["Pending"] + +[[state]] +name = "agent_id" +type = "string" +initial = "" + +[[action]] +name = "Initialize" +kind = "input" +from = ["Pending"] +params = ["agent_id"] + +[[invariant]] +name = "RequiresAgentId" +when = ["Pending"] +assert = "agent_id != ''" +"#; + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = Arc::new(TransitionTable::from_ioa_source(ioa)); + let mut invalid = EntityActorHandler::new("ToolCall", "tc-invalid", table.clone()); + invalid + .init() + .expect("action-backed pristine state must await initialization"); + assert_eq!(invalid.event_count(), 0); + invalid + .handle_message("Initialize", r#"{"agent_id":"agent-1"}"#) + .expect("initializing action establishes runtime safety"); + assert_eq!(invalid.event_count(), 1); + + let mut valid = EntityActorHandler::new("ToolCall", "tc-valid", table) + .with_initial_fields(serde_json::json!({"agent_id": "agent-1"})); + valid + .init() + .expect("matching initial fields satisfy the production gate"); +} + +#[test] +fn runtime_counter_invariant_rejects_over_quota_mutation_atomically() { + const WORKSPACE_IOA: &str = + include_str!("../../../../../os-apps/temper-fs/specs/workspace.ioa.toml"); + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let table = Arc::new(TransitionTable::from_ioa_source(WORKSPACE_IOA)); + let mut handler = EntityActorHandler::new("Workspace", "w1", table); + handler.init().expect("initialize workspace"); + handler + .handle_message("Create", r#"{"name":"docs","quota_limit":10}"#) + .expect("set initial quota"); + + let rejected = handler.handle_message("IncrementUsage", r#"{"size_bytes":11}"#); + assert!(rejected.is_err()); + assert_eq!( + handler + .state + .counters + .get("used_bytes") + .copied() + .unwrap_or(0), + 0 + ); + assert_eq!(handler.state.counters.get("quota_limit"), Some(&10)); + assert_eq!(handler.event_count(), 1); + + handler + .handle_message("IncrementUsage", r#"{"size_bytes":10}"#) + .expect("usage at quota is valid"); + assert_eq!(handler.state.counters.get("used_bytes"), Some(&10)); +} diff --git a/crates/temper-server/src/entity_actor/types.rs b/crates/temper-server/src/entity_actor/types.rs index a3e23d142..1a4d8df7c 100644 --- a/crates/temper-server/src/entity_actor/types.rs +++ b/crates/temper-server/src/entity_actor/types.rs @@ -51,6 +51,8 @@ pub enum EntityMsg { }, /// Get the current entity state. GetState, + /// Persist a final snapshot and stop without admitting another mutation. + Passivate, /// Get a specific field value. GetField { field: String }, /// Update entity fields (PATCH: merge, PUT: replace). diff --git a/crates/temper-server/src/observe/mod_test.rs b/crates/temper-server/src/observe/mod_test.rs index b5bf6abd0..25e7018e5 100644 --- a/crates/temper-server/src/observe/mod_test.rs +++ b/crates/temper-server/src/observe/mod_test.rs @@ -142,7 +142,7 @@ async fn projection_replay_parity_endpoint_reports_clean_bounded_scope() { .await; assert_eq!(json["kind"], "query_projection_replay_parity"); - assert_eq!(json["clean"], true); + assert_eq!(json["clean"], true, "{json:#}"); assert_eq!(json["limit"], 1); assert_eq!(json["report"]["tenant"], "default"); assert_eq!(json["report"]["entity_type"], "Order"); @@ -198,7 +198,10 @@ async fn projection_replay_parity_endpoint_reports_projection_drift() { assert_eq!(json["report"]["matched"], 0); assert_eq!(json["report"]["drifted"], 1); assert_eq!(json["report"]["drift_examples"][0]["entity_id"], entity_id); - assert_eq!(json["report"]["drift_examples"][0]["drift_kind"], "fields"); + assert_eq!( + json["report"]["drift_examples"][0]["drift_kind"], "fields", + "{json:#}" + ); } /// Build a POST request with admin auth headers for observe endpoints. diff --git a/crates/temper-server/src/observe/specs/verification_stream.rs b/crates/temper-server/src/observe/specs/verification_stream.rs index 7b2156a59..624b47c30 100644 --- a/crates/temper-server/src/observe/specs/verification_stream.rs +++ b/crates/temper-server/src/observe/specs/verification_stream.rs @@ -209,87 +209,10 @@ pub(super) fn build_verification_stream_response( } } - // Build EntityVerificationResult for registry - let entity_result = crate::registry::EntityVerificationResult { - all_passed: cascade_result.all_passed, - levels: cascade_result - .levels - .iter() - .map(|l| { - let details: Option> = if !l.passed { - let mut dets = Vec::new(); - if let Some(sim) = &l.simulation { - for v in &sim.liveness_violations { - dets.push(crate::registry::VerificationDetail { - kind: "liveness_violation".into(), - property: v.property.clone(), - description: v.description.clone(), - actor_id: Some(v.actor_id.clone()), - }); - } - for v in &sim.violations { - dets.push(crate::registry::VerificationDetail { - kind: "invariant_violation".into(), - property: v.invariant.clone(), - description: format!( - "Actor {} violated invariant at tick {} during action {}", - v.actor_id, v.tick, v.action - ), - actor_id: Some(v.actor_id.clone()), - }); - } - } - if let Some(mc) = &l.verification { - for cx in &mc.counterexamples { - dets.push(crate::registry::VerificationDetail { - kind: "counterexample".into(), - property: cx.property.clone(), - description: format!( - "Counterexample found with {} step trace", - cx.trace.len() - ), - actor_id: None, - }); - } - for transition in &mc.dead_transitions { - dets.push(crate::registry::VerificationDetail { - kind: "dead_transition".into(), - property: transition.clone(), - description: - "Transition is unreachable in model check" - .into(), - actor_id: None, - }); - } - } - if let Some(pt) = &l.prop_test - && let Some(failure) = &pt.failure - { - dets.push(crate::registry::VerificationDetail { - kind: "proptest_failure".into(), - property: failure.invariant.clone(), - description: format!( - "Property test failed after sequence: {}", - failure.action_sequence.join(" -> ") - ), - actor_id: None, - }); - } - if dets.is_empty() { None } else { Some(dets) } - } else { - None - }; - - crate::registry::EntityLevelSummary { - level: format!("{}", l.level), - passed: l.passed, - summary: l.summary.clone(), - details, - } - }) - .collect(), - verified_at: sim_now().to_rfc3339(), - }; + let entity_result = crate::registry::EntityVerificationResult::from_cascade( + &cascade_result, + sim_now().to_rfc3339(), + ); // Stream verification_result with full level details let levels_json: Vec = entity_result @@ -314,6 +237,8 @@ pub(super) fn build_verification_stream_response( "entity": entity_name, "all_passed": cascade_result.all_passed, "levels": levels_json, + "warnings": &entity_result.warnings, + "errors": &entity_result.errors, })) .unwrap() // ci-ok: infallible serialization + "\n")) @@ -395,6 +320,8 @@ pub(super) fn build_verification_stream_response( summary: format!("Verification failed for {entity_name}: {e}"), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: sim_now().to_rfc3339(), }; if let Err(persist_err) = state_for_task diff --git a/crates/temper-server/src/observe/verification/cascade.rs b/crates/temper-server/src/observe/verification/cascade.rs index 146fd0efb..99c3acfef 100644 --- a/crates/temper-server/src/observe/verification/cascade.rs +++ b/crates/temper-server/src/observe/verification/cascade.rs @@ -88,76 +88,8 @@ pub(crate) async fn handle_run_verification( })?; } - let entity_result = crate::registry::EntityVerificationResult { - all_passed: result.all_passed, - levels: result - .levels - .iter() - .map(|l| { - let details: Option> = if !l.passed { - let mut dets = Vec::new(); - if let Some(sim) = &l.simulation { - for v in &sim.liveness_violations { - dets.push(crate::registry::VerificationDetail { - kind: "liveness_violation".into(), - property: v.property.clone(), - description: v.description.clone(), - actor_id: Some(v.actor_id.clone()), - }); - } - for v in &sim.violations { - dets.push(crate::registry::VerificationDetail { - kind: "invariant_violation".into(), - property: v.invariant.clone(), - description: format!( - "Actor {} violated invariant at tick {} during action {}", - v.actor_id, v.tick, v.action - ), - actor_id: Some(v.actor_id.clone()), - }); - } - } - if let Some(mc) = &l.verification { - for cx in &mc.counterexamples { - dets.push(crate::registry::VerificationDetail { - kind: "counterexample".into(), - property: cx.property.clone(), - description: format!( - "Counterexample found with {} step trace", - cx.trace.len() - ), - actor_id: None, - }); - } - } - if let Some(pt) = &l.prop_test - && let Some(failure) = &pt.failure - { - dets.push(crate::registry::VerificationDetail { - kind: "proptest_failure".into(), - property: failure.invariant.clone(), - description: format!( - "Property test failed after sequence: {}", - failure.action_sequence.join(" → ") - ), - actor_id: None, - }); - } - if dets.is_empty() { None } else { Some(dets) } - } else { - None - }; - - crate::registry::EntityLevelSummary { - level: l.level.to_string(), - passed: l.passed, - summary: l.summary.clone(), - details, - } - }) - .collect(), - verified_at: sim_now().to_rfc3339(), - }; + let entity_result = + crate::registry::EntityVerificationResult::from_cascade(&result, sim_now().to_rfc3339()); let passed_count = entity_result.levels.iter().filter(|l| l.passed).count(); let final_status = if entity_result.all_passed { "passed" diff --git a/crates/temper-server/src/odata/bindings.rs b/crates/temper-server/src/odata/bindings.rs index ffe35b806..50d48046c 100644 --- a/crates/temper-server/src/odata/bindings.rs +++ b/crates/temper-server/src/odata/bindings.rs @@ -223,7 +223,14 @@ pub(super) async fn dispatch_bound_action( .into_response(); } - let current_fields = current_state.state.fields.clone(); + let mut prospective_fields = current_state.state.fields.clone(); + if let (Some(fields), Some(params)) = + (prospective_fields.as_object_mut(), body_json.as_object()) + { + for (name, value) in params { + fields.insert(name.clone(), value.clone()); + } + } if let Err(resp) = run_write_prechecks( state, tenant, @@ -231,7 +238,7 @@ pub(super) async fn dispatch_bound_action( key_str, action, "bound_action", - ¤t_fields, + &prospective_fields, ) .await { diff --git a/crates/temper-server/src/odata/read.rs b/crates/temper-server/src/odata/read.rs index 3a7e36e10..caf450e95 100644 --- a/crates/temper-server/src/odata/read.rs +++ b/crates/temper-server/src/odata/read.rs @@ -242,6 +242,9 @@ async fn load_existing_entity_body( .get_tenant_entity_state(tenant, entity_type, key) .await .map_err(|_| resource_not_found_response(set_name, key))?; + if !response.success { + return Err(resource_not_found_response(set_name, key)); + } let mut body = serde_json::to_value(&response.state).unwrap_or_default(); hydrate_blob_refs_for_tenant(state, tenant, &mut body).await; if let Some(obj) = body.as_object_mut() { diff --git a/crates/temper-server/src/odata/read_support.rs b/crates/temper-server/src/odata/read_support.rs index 59d6780ec..a7d53fe6e 100644 --- a/crates/temper-server/src/odata/read_support.rs +++ b/crates/temper-server/src/odata/read_support.rs @@ -301,7 +301,7 @@ pub(super) async fn materialize_entity_set_entities( .get_tenant_entity_state(&tenant, &entity_type, &id) .await { - Ok(response) => { + Ok(response) if response.success => { if response.state.status != "Deleted" && let Some(query_plane) = state.query_plane_store() { @@ -342,6 +342,14 @@ pub(super) async fn materialize_entity_set_entities( } Some(entity) } + Ok(response) => { + tracing::debug!( + error = ?response.error, + entity_id = %id, + "skipping unreadable entity during OData materialization" + ); + None + } Err(error) => { tracing::debug!( error = %error, diff --git a/crates/temper-server/src/registry/mod.rs b/crates/temper-server/src/registry/mod.rs index 8319e7208..e3da29dad 100644 --- a/crates/temper-server/src/registry/mod.rs +++ b/crates/temper-server/src/registry/mod.rs @@ -5,8 +5,15 @@ //! in `ServerState`, enabling multi-tenant deployments where each tenant has //! its own entity types and specs. +mod model_contract; +#[cfg(test)] +mod model_contract_additive_tests; +#[cfg(test)] +mod model_contract_tests; mod relations; pub mod types; +#[cfg(test)] +mod unsupported_safety_tests; use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; @@ -26,6 +33,7 @@ use crate::trigger::types::ReactionRule; pub use types::*; +use model_contract::model_safety_contract_changed; use relations::{build_relation_graph, build_webhook_routes, synthesize_action_trigger_reaction}; fn merge_reaction_rules( @@ -152,6 +160,24 @@ impl SpecRegistry { ) -> Result<(), RegistryError> { let tenant = tenant.into(); let tenant_name = tenant.to_string(); + let mut parsed_automata = BTreeMap::new(); + for (entity_type, ioa_source) in ioa_sources { + let parsed = + automaton::parse_automaton(ioa_source).map_err(|e| RegistryError::IoaParse { + tenant: tenant_name.clone(), + entity_type: (*entity_type).to_string(), + source: e.to_string(), + })?; + let unsupported = automaton::unsupported_safety_invariant_names(&parsed); + if !unsupported.is_empty() { + return Err(RegistryError::UnsupportedSafetyInvariants { + tenant: tenant_name.clone(), + entity_type: (*entity_type).to_string(), + invariants: unsupported, + }); + } + parsed_automata.insert((*entity_type).to_string(), parsed); + } let cross_invariants = cross_invariants_source .as_ref() .filter(|s| !s.trim().is_empty()) @@ -164,6 +190,36 @@ impl SpecRegistry { .transpose()?; let relation_graph = build_relation_graph(&csdl, cross_invariants.as_ref()); + // Runtime and model-proved safety contracts apply to snapshots, replay + // tails, projected rows, and live actors. A synchronous table hot-swap + // cannot prove that every existing durable entity belongs to the new + // model's reachable state space. Reject contract or model changes + // atomically; an explicit migration must validate state first. + if let Some(existing_config) = self.tenants.get(&tenant) { + for (entity_type, _) in ioa_sources { + let Some(existing_spec) = existing_config.entities.get(*entity_type) else { + continue; + }; + let incoming = automaton::compile_runtime_invariants( + parsed_automata + .get(*entity_type) + .expect("incoming automaton preflighted"), + ); + let current = automaton::compile_runtime_invariants(&existing_spec.automaton); + let incoming_automaton = parsed_automata + .get(*entity_type) + .expect("incoming automaton preflighted"); + if incoming != current + || model_safety_contract_changed(&existing_spec.automaton, incoming_automaton) + { + return Err(RegistryError::RuntimeInvariantMigrationRequired { + tenant: tenant_name.clone(), + entity_type: (*entity_type).to_string(), + }); + } + } + } + // Build entity set map from CSDL let mut entity_set_map = BTreeMap::new(); for schema in &csdl.schemas { @@ -212,13 +268,10 @@ impl SpecRegistry { } for (entity_type, ioa_source) in ioa_sources { - let automaton = automaton::parse_automaton(ioa_source).map_err(|e| { - RegistryError::IoaParse { - tenant: tenant_name.clone(), - entity_type: (*entity_type).to_string(), - source: e.to_string(), - } - })?; + let automaton = parsed_automata + .get(*entity_type) + .expect("incoming automaton preflighted") + .clone(); let table = TransitionTable::from_automaton(&automaton); let integrations = automaton.integrations.clone(); @@ -279,13 +332,10 @@ impl SpecRegistry { // First registration: create new TenantConfig. let mut entities = BTreeMap::new(); for (entity_type, ioa_source) in ioa_sources { - let automaton = automaton::parse_automaton(ioa_source).map_err(|e| { - RegistryError::IoaParse { - tenant: tenant_name.clone(), - entity_type: (*entity_type).to_string(), - source: e.to_string(), - } - })?; + let automaton = parsed_automata + .get(*entity_type) + .expect("incoming automaton preflighted") + .clone(); let table = TransitionTable::from_automaton(&automaton); let integrations = automaton.integrations.clone(); entities.insert( @@ -859,4 +909,57 @@ assert = 'related(Order, OrderId).status in ["Active"]' "Task exists after replace" ); } + + #[test] + fn hot_swap_rejects_runtime_contract_change_before_mutating_registry() { + const ORDER_WITH_RUNTIME_INVARIANT: &str = r#" +[automaton] +name = "Order" +states = ["Draft"] +initial = "Draft" +allow_indefinite_states = ["Draft"] + +[[state]] +name = "title" +type = "string" +initial = "ready" + +[[invariant]] +name = "TitleRequired" +when = ["Draft"] +assert = "title != ''" +"#; + + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", ORDER_IOA)]); + let original_source = registry + .get_spec(&TenantId::new("alpha"), "Order") + .unwrap() + .ioa_source + .clone(); + + let (replacement_csdl, replacement_xml) = minimal_csdl(); + let error = registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", ORDER_WITH_RUNTIME_INVARIANT)], + ) + .expect_err("runtime contract changes require an explicit durable-state migration"); + + assert!(matches!( + error, + RegistryError::RuntimeInvariantMigrationRequired { .. } + )); + assert_eq!( + registry + .get_spec(&TenantId::new("alpha"), "Order") + .unwrap() + .ioa_source, + original_source, + "rejected activation must leave the prior registry untouched" + ); + } } diff --git a/crates/temper-server/src/registry/model_contract.rs b/crates/temper-server/src/registry/model_contract.rs new file mode 100644 index 000000000..7d79a3007 --- /dev/null +++ b/crates/temper-server/src/registry/model_contract.rs @@ -0,0 +1,214 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use temper_spec::automaton; + +pub(super) fn model_safety_contract_changed( + current: &automaton::Automaton, + incoming: &automaton::Automaton, +) -> bool { + if current.invariants.is_empty() && incoming.invariants.is_empty() { + return false; + } + + let invariant_signatures = |spec: &automaton::Automaton| { + spec.invariants + .iter() + .map(|invariant| { + let mut when = invariant.when.clone(); + when.sort(); + (invariant.name.clone(), when, invariant.assert.clone()) + }) + .collect::>() + }; + if invariant_signatures(current) != invariant_signatures(incoming) { + return true; + } + + !model_reachability_semantics(incoming) + .is_compatible_extension_of(&model_reachability_semantics(current)) +} + +#[derive(PartialEq)] +struct ModelReachabilitySemantics { + status_values: Vec, + initial_status: String, + state_initials: BTreeMap, + actions: Vec, + invariant_trigger_states: BTreeSet, + terminal_states: BTreeSet, + has_global_terminal_invariant: bool, +} + +impl ModelReachabilitySemantics { + fn is_compatible_extension_of(&self, current: &Self) -> bool { + if self.initial_status != current.initial_status + || self.state_initials != current.state_initials + || !current + .status_values + .iter() + .all(|state| self.status_values.contains(state)) + { + return false; + } + + let mut incoming_actions = self.actions.iter().collect::>(); + for current_action in ¤t.actions { + let Some(index) = incoming_actions + .iter() + .position(|incoming| *incoming == current_action) + else { + return false; + }; + incoming_actions.remove(index); + } + + let current_names = current + .actions + .iter() + .map(|action| action.name.as_str()) + .collect::>(); + let mut added_names = BTreeSet::new(); + incoming_actions.into_iter().all(|action| { + !current_names.contains(action.name.as_str()) + && added_names.insert(action.name.as_str()) + && self.added_action_is_syntactically_safe(action, current) + }) + } + + fn added_action_is_syntactically_safe( + &self, + action: &ModelActionSemantics, + current: &Self, + ) -> bool { + if self.has_global_terminal_invariant + || !action.effects.is_empty() + || action.from_states.is_empty() + || !action + .from_states + .iter() + .all(|state| current.status_values.contains(state)) + || action + .from_states + .iter() + .any(|state| self.terminal_states.contains(state)) + { + return false; + } + + let Some(target) = action.to_state.as_ref() else { + return true; + }; + if action.from_states.iter().all(|source| source == target) { + return true; + } + + self.status_values.contains(target) + && !current.status_values.contains(target) + && !self.invariant_trigger_states.contains(target) + } +} + +#[derive(PartialEq)] +struct ModelActionSemantics { + name: String, + from_states: Vec, + to_state: Option, + guard: automaton::ResolvedGuard, + effects: Vec, +} + +fn model_reachability_semantics(spec: &automaton::Automaton) -> ModelReachabilitySemantics { + let states = spec + .state + .iter() + .filter(|state| { + matches!( + state.var_type.as_str(), + "bool" | "counter" | "list" | "set" | "string" + ) + }) + .map(|state| { + ( + state.name.clone(), + ( + state.var_type.clone(), + automaton::parse_var_initial_json(&state.var_type, &state.initial), + ), + ) + }) + .collect::>(); + let actions = automaton::translate_actions(spec) + .into_iter() + .map(|action| ModelActionSemantics { + name: action.name, + from_states: action.from_states, + to_state: action.to_state, + guard: normalized_model_guard(action.guard), + effects: action.effects, + }) + .collect::>(); + + let mut status_values = spec.automaton.states.clone(); + status_values.sort(); + let invariant_trigger_states = spec + .invariants + .iter() + .flat_map(|invariant| invariant.when.iter().cloned()) + .collect(); + let terminal_states = spec + .invariants + .iter() + .filter(|invariant| assertion_requires_no_further_transitions(&invariant.assert)) + .flat_map(|invariant| invariant.when.iter().cloned()) + .collect(); + ModelReachabilitySemantics { + status_values, + initial_status: spec.automaton.initial.clone(), + state_initials: states, + actions, + invariant_trigger_states, + terminal_states, + has_global_terminal_invariant: spec.invariants.iter().any(|invariant| { + invariant.when.is_empty() + && assertion_requires_no_further_transitions(&invariant.assert) + }), + } +} + +fn assertion_requires_no_further_transitions(assertion: &str) -> bool { + fn contains_terminal(assertion: &automaton::ParsedAssert) -> bool { + match assertion { + automaton::ParsedAssert::NoFurtherTransitions => true, + automaton::ParsedAssert::And(parts) | automaton::ParsedAssert::Or(parts) => { + parts.iter().any(contains_terminal) + } + _ => false, + } + } + + automaton::parse_assert_expr(assertion) + .as_ref() + .is_some_and(contains_terminal) +} + +fn normalized_model_guard(guard: automaton::ResolvedGuard) -> automaton::ResolvedGuard { + match guard { + automaton::ResolvedGuard::CrossEntityState { + entity_type, + entity_id_source, + required_status, + forbidden_status, + required: _, + } => automaton::ResolvedGuard::CrossEntityState { + entity_type, + entity_id_source, + required_status, + forbidden_status, + required: false, + }, + automaton::ResolvedGuard::And(guards) => { + automaton::ResolvedGuard::And(guards.into_iter().map(normalized_model_guard).collect()) + } + other => other, + } +} diff --git a/crates/temper-server/src/registry/model_contract_additive_tests.rs b/crates/temper-server/src/registry/model_contract_additive_tests.rs new file mode 100644 index 000000000..8b24dc4c8 --- /dev/null +++ b/crates/temper-server/src/registry/model_contract_additive_tests.rs @@ -0,0 +1,306 @@ +use super::*; +use temper_spec::csdl::parse_csdl; + +const CSDL_XML: &str = include_str!("../../../../test-fixtures/specs/model.csdl.xml"); + +const EXTENSIBLE_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" + +[[state]] +name = "budget" +type = "counter" +initial = "0" + +[[action]] +name = "StaySafe" +kind = "input" +from = ["Active"] +to = "Active" + +[[invariant]] +name = "AlwaysSafe" +when = ["Active"] +assert = "safe" +"#; + +fn minimal_csdl() -> (temper_spec::csdl::CsdlDocument, String) { + let doc = parse_csdl(CSDL_XML).expect("CSDL should parse"); + (doc, CSDL_XML.to_string()) +} + +#[test] +fn hot_swap_allows_verified_additive_model_extension() { + let extended_base = EXTENSIBLE_IOA.replacen( + "states = [\"Active\"]", + "states = [\"Active\", \"Archived\"]", + 1, + ); + let extended = format!( + r#"{extended_base} + +[[action]] +name = "ArchiveSafe" +kind = "input" +from = ["Active"] +to = "Archived" +"# + ); + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", EXTENSIBLE_IOA)]); + let tenant = TenantId::new("alpha"); + let original_lock = registry + .get_spec(&tenant, "Order") + .unwrap() + .swap_controller() + .current(); + + let (replacement_csdl, replacement_xml) = minimal_csdl(); + registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", &extended)], + ) + .expect("proof-preserving additive extensions remain hot-swappable"); + + let current = registry.get_spec(&tenant, "Order").unwrap(); + assert_eq!(current.ioa_source, extended); + assert!(Arc::ptr_eq( + ¤t.swap_controller().current(), + &original_lock + )); + assert_eq!(current.table().rules.len(), 2); +} + +#[test] +fn hot_swap_allows_effect_free_self_loop_under_global_state_invariant() { + let original = EXTENSIBLE_IOA.replace("when = [\"Active\"]", "when = []"); + let incoming = format!( + r#"{original} + +[[action]] +name = "ConfirmSafe" +kind = "input" +from = ["Active"] +to = "Active" +"# + ); + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", &original)]); + + let (replacement_csdl, replacement_xml) = minimal_csdl(); + registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", &incoming)], + ) + .expect("an effect-free self-loop preserves a global state invariant"); +} + +#[test] +fn hot_swap_rejects_unsafe_unique_additive_action_before_mutating_registry() { + let unsafe_extension = format!( + r#"{EXTENSIBLE_IOA} + +[[action]] +name = "BreakSafety" +kind = "input" +from = ["Active"] +to = "Active" +effect = [{{ type = "set_bool", var = "safe", value = "false" }}] +"# + ); + assert_addition_rejected(&unsafe_extension, "unverified additive model mutation"); +} + +#[test] +fn hot_swap_rejects_parameterized_counter_effect_before_mutating_registry() { + let unsafe_extension = format!( + r#"{EXTENSIBLE_IOA} + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +effect = [{{ type = "set_counter_from_param", var = "budget", param = "value" }}] +"# + ); + assert_addition_rejected( + &unsafe_extension, + "parameterized runtime effects are not effect-free", + ); +} + +#[test] +fn hot_swap_rejects_parameterized_effect_mutation_before_mutating_registry() { + let original = format!( + r#"{EXTENSIBLE_IOA} + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +effect = [{{ type = "set_counter_from_param", var = "budget", param = "value" }}] +"# + ); + let incoming = original.replacen("param = \"value\"", "param = \"replacement\"", 1); + assert_swap_rejected( + &original, + &incoming, + "existing runtime effects must retain exact replay semantics", + ); +} + +#[test] +fn hot_swap_rejects_action_under_global_terminal_invariant() { + const TERMINAL_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Stopped"] +initial = "Stopped" +allow_indefinite_states = ["Stopped"] + +[[invariant]] +name = "GloballyTerminal" +when = [] +assert = "no_further_transitions" +"#; + let incoming = format!( + r#"{TERMINAL_IOA} + +[[action]] +name = "Restart" +kind = "input" +from = ["Stopped"] +to = "Stopped" +"# + ); + assert_swap_rejected( + TERMINAL_IOA, + &incoming, + "global invariants forbid unverified additive actions", + ); +} + +#[test] +fn hot_swap_rejects_action_under_compound_global_terminal_invariant() { + assert_terminal_addition_rejected( + &[], + "safe && no_further_transitions", + "compound global terminal assertions remain terminal", + ); +} + +#[test] +fn hot_swap_rejects_action_under_compound_scoped_terminal_invariant() { + assert_terminal_addition_rejected( + &["Stopped"], + "safe || no_further_transitions", + "compound scoped terminal assertions fail closed", + ); +} + +fn assert_terminal_addition_rejected(when: &[&str], assertion: &str, reason: &str) { + let when = serde_json::to_string(when).unwrap(); + let original = format!( + r#" +[automaton] +name = "Order" +states = ["Stopped"] +initial = "Stopped" +allow_indefinite_states = ["Stopped"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" + +[[invariant]] +name = "Terminal" +when = {when} +assert = "{assertion}" +"# + ); + let incoming = format!( + r#"{original} + +[[action]] +name = "Restart" +kind = "input" +from = ["Stopped"] +to = "Stopped" +"# + ); + assert_swap_rejected(&original, &incoming, reason); +} + +#[test] +fn hot_swap_rejects_additive_action_name_collision() { + let colliding = format!( + r#"{EXTENSIBLE_IOA} + +[[action]] +name = "StaySafe" +kind = "input" +from = ["Active"] +to = "Active" +effect = [{{ type = "set_bool", var = "safe", value = "true" }}] +"# + ); + assert_addition_rejected(&colliding, "new action identity collision"); +} + +fn assert_addition_rejected(incoming: &str, reason: &str) { + assert_swap_rejected(EXTENSIBLE_IOA, incoming, reason); +} + +fn assert_swap_rejected(original: &str, incoming: &str, reason: &str) { + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", original)]); + let tenant = TenantId::new("alpha"); + let original_spec = registry.get_spec(&tenant, "Order").unwrap(); + let original_lock = original_spec.swap_controller().current(); + let original_table = serde_json::to_value(&*original_spec.table()).unwrap(); + + let (replacement_csdl, replacement_xml) = minimal_csdl(); + let error = registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", incoming)], + ) + .expect_err(reason); + + assert!(matches!( + error, + RegistryError::RuntimeInvariantMigrationRequired { .. } + )); + let current = registry.get_spec(&tenant, "Order").unwrap(); + assert_eq!(current.ioa_source, original); + assert!(Arc::ptr_eq( + ¤t.swap_controller().current(), + &original_lock + )); + assert_eq!( + serde_json::to_value(&*current.table()).unwrap(), + original_table + ); +} diff --git a/crates/temper-server/src/registry/model_contract_tests.rs b/crates/temper-server/src/registry/model_contract_tests.rs new file mode 100644 index 000000000..8d90be187 --- /dev/null +++ b/crates/temper-server/src/registry/model_contract_tests.rs @@ -0,0 +1,417 @@ +use super::*; +use temper_spec::csdl::parse_csdl; + +const CSDL_XML: &str = include_str!("../../../../test-fixtures/specs/model.csdl.xml"); + +fn minimal_csdl() -> (temper_spec::csdl::CsdlDocument, String) { + let doc = parse_csdl(CSDL_XML).expect("CSDL should parse"); + (doc, CSDL_XML.to_string()) +} + +#[test] +fn hot_swap_rejects_guard_only_model_contract_before_mutating_registry() { + const OLD_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" + +[[state]] +name = "unlock" +type = "bool" +initial = "false" + +[[action]] +name = "BreakSafety" +kind = "input" +from = ["Active"] +to = "Active" +guard = [{ type = "is_true", var = "unlock" }] +effect = [{ type = "set_bool", var = "safe", value = "false" }] +"#; + const VERIFIED_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" + +[[state]] +name = "unlock" +type = "bool" +initial = "false" + +[[action]] +name = "BreakSafety" +kind = "input" +from = ["Active"] +to = "Active" +guard = [{ type = "is_true", var = "unlock" }] +effect = [{ type = "set_bool", var = "safe", value = "false" }] + +[[invariant]] +name = "AlwaysSafe" +when = ["Active"] +assert = "safe" +"#; + + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", OLD_IOA)]); + let tenant = TenantId::new("alpha"); + let original_spec = registry.get_spec(&tenant, "Order").unwrap(); + let original_lock = original_spec.swap_controller().current(); + let original_table = original_spec.table(); + assert!( + original_table.model_protected_state_vars.is_empty(), + "the old contract permits durable caller-authored boolean state" + ); + let original_table_json = serde_json::to_value(&*original_table).unwrap(); + let original = registry.get_tenant(&tenant).unwrap(); + let original_source = original.entities["Order"].ioa_source.clone(); + let original_csdl = Arc::clone(&original.csdl); + let original_csdl_xml = Arc::clone(&original.csdl_xml); + + let (replacement_csdl, replacement_xml) = minimal_csdl(); + let error = registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", VERIFIED_IOA)], + ) + .expect_err("a new reachability contract requires durable-state migration"); + + assert!(matches!( + error, + RegistryError::RuntimeInvariantMigrationRequired { .. } + )); + let current = registry.get_tenant(&tenant).unwrap(); + assert_eq!(current.entities["Order"].ioa_source, original_source); + assert!(Arc::ptr_eq(¤t.csdl, &original_csdl)); + assert!(Arc::ptr_eq(¤t.csdl_xml, &original_csdl_xml)); + let current_spec = registry.get_spec(&tenant, "Order").unwrap(); + let current_lock = current_spec.swap_controller().current(); + assert!(Arc::ptr_eq(¤t_lock, &original_lock)); + let current_table = current_spec.table(); + assert_eq!( + serde_json::to_value(&*current_table).unwrap(), + original_table_json, + "rejection must leave the prior live transition table untouched" + ); +} + +#[test] +fn hot_swap_allows_metadata_only_change_under_same_model_contract() { + const ORIGINAL_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" + +[[invariant]] +name = "AlwaysSafe" +when = ["Active"] +assert = "safe" +"#; + const METADATA_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" +overflow_inline_max_bytes = 4096 +overflow_ttl_seconds = 60 + +[[invariant]] +name = "AlwaysSafe" +when = ["Active"] +assert = "safe" +"#; + + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", ORIGINAL_IOA)]); + let original_lock = registry + .get_spec(&TenantId::new("alpha"), "Order") + .unwrap() + .swap_controller() + .current(); + + let (replacement_csdl, replacement_xml) = minimal_csdl(); + registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", METADATA_IOA)], + ) + .expect("runtime-only metadata must remain hot-swappable"); + + let current = registry.get_spec(&TenantId::new("alpha"), "Order").unwrap(); + assert!(Arc::ptr_eq( + ¤t.swap_controller().current(), + &original_lock + )); + let metadata = ¤t.table().state_var_metadata["safe"]; + assert_eq!(metadata.overflow_inline_max_bytes, Some(4096)); + assert_eq!(metadata.overflow_ttl_seconds, Some(60)); +} + +#[test] +fn hot_swap_rejects_string_initial_change_under_runtime_invariant() { + const ORIGINAL_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "title" +type = "string" +initial = "ready" + +[[invariant]] +name = "TitleRequired" +when = ["Active"] +assert = "title != ''" +"#; + const CHANGED_INITIAL_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "title" +type = "string" +initial = "" + +[[invariant]] +name = "TitleRequired" +when = ["Active"] +assert = "title != ''" +"#; + + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", ORIGINAL_IOA)]); + let tenant = TenantId::new("alpha"); + let original = registry.get_spec(&tenant, "Order").unwrap(); + let original_lock = original.swap_controller().current(); + let original_source = original.ioa_source.clone(); + let original_table = serde_json::to_value(&*original.table()).unwrap(); + + let (replacement_csdl, replacement_xml) = minimal_csdl(); + let error = registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", CHANGED_INITIAL_IOA)], + ) + .expect_err("changing a string initial under a safety invariant requires migration"); + + assert!(matches!( + error, + RegistryError::RuntimeInvariantMigrationRequired { .. } + )); + let current = registry.get_spec(&tenant, "Order").unwrap(); + assert_eq!(current.ioa_source, original_source); + assert!(Arc::ptr_eq( + ¤t.swap_controller().current(), + &original_lock + )); + assert_eq!( + serde_json::to_value(&*current.table()).unwrap(), + original_table, + "rejection must leave the prior live transition table untouched" + ); +} + +#[test] +fn hot_swap_rejects_reachability_change_under_same_model_invariant() { + const GUARDED_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" + +[[state]] +name = "unlock" +type = "bool" +initial = "false" + +[[action]] +name = "ChangeReachability" +kind = "input" +from = ["Active"] +to = "Active" +guard = [{ type = "is_true", var = "unlock" }] +effect = [{ type = "set_bool", var = "unlock", value = "false" }] + +[[invariant]] +name = "AlwaysSafe" +when = ["Active"] +assert = "safe" +"#; + const REACHABLE_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" + +[[state]] +name = "unlock" +type = "bool" +initial = "false" + +[[action]] +name = "ChangeReachability" +kind = "input" +from = ["Active"] +to = "Active" +guard = [{ type = "is_false", var = "unlock" }] +effect = [{ type = "set_bool", var = "unlock", value = "true" }] + +[[invariant]] +name = "AlwaysSafe" +when = ["Active"] +assert = "safe" +"#; + + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", GUARDED_IOA)]); + let tenant = TenantId::new("alpha"); + let original_source = registry + .get_spec(&tenant, "Order") + .unwrap() + .ioa_source + .clone(); + + let (replacement_csdl, replacement_xml) = minimal_csdl(); + let error = registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", REACHABLE_IOA)], + ) + .expect_err("changed reachable-state semantics require durable-state migration"); + + assert!(matches!( + error, + RegistryError::RuntimeInvariantMigrationRequired { .. } + )); + assert_eq!( + registry.get_spec(&tenant, "Order").unwrap().ioa_source, + original_source + ); +} + +#[test] +fn hot_swap_rejects_action_identity_collision_before_mutation() { + const UNIQUE_ACTIONS_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Unchecked", "Checked"] +initial = "Unchecked" +allow_indefinite_states = ["Unchecked", "Checked"] + +[[state]] +name = "safe" +type = "bool" +initial = "true" + +[[action]] +name = "CorruptUnchecked" +kind = "input" +from = ["Unchecked"] +to = "Unchecked" +effect = [{ type = "set_bool", var = "safe", value = "false" }] + +[[action]] +name = "EnterChecked" +kind = "input" +from = ["Unchecked"] +to = "Checked" +effect = [{ type = "set_bool", var = "safe", value = "true" }] + +[[invariant]] +name = "CheckedIsSafe" +when = ["Checked"] +assert = "safe" +"#; + + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", UNIQUE_ACTIONS_IOA)]); + let tenant = TenantId::new("alpha"); + let original_spec = registry.get_spec(&tenant, "Order").unwrap(); + let original_lock = original_spec.swap_controller().current(); + let original_table = serde_json::to_value(&*original_spec.table()).unwrap(); + let duplicate_actions = + UNIQUE_ACTIONS_IOA.replacen("name = \"EnterChecked\"", "name = \"CorruptUnchecked\"", 1); + + let (replacement_csdl, replacement_xml) = minimal_csdl(); + let error = registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", &duplicate_actions)], + ) + .expect_err("action identity changes L1 effect resolution"); + + assert!(matches!( + error, + RegistryError::RuntimeInvariantMigrationRequired { .. } + )); + let current = registry.get_spec(&tenant, "Order").unwrap(); + assert_eq!(current.ioa_source, UNIQUE_ACTIONS_IOA); + assert!(Arc::ptr_eq( + ¤t.swap_controller().current(), + &original_lock + )); + assert_eq!( + serde_json::to_value(&*current.table()).unwrap(), + original_table + ); +} diff --git a/crates/temper-server/src/registry/types.rs b/crates/temper-server/src/registry/types.rs index ad46887db..766cad185 100644 --- a/crates/temper-server/src/registry/types.rs +++ b/crates/temper-server/src/registry/types.rs @@ -44,10 +44,124 @@ pub struct EntityVerificationResult { pub all_passed: bool, /// Per-level summaries. pub levels: Vec, + /// Non-fatal disclosures, including runtime-only enforcement contracts. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, + /// Structured capability errors that blocked backend verification. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub errors: Vec, /// ISO-8601 timestamp when verification completed. pub verified_at: String, } +impl EntityVerificationResult { + /// Preserve the complete cascade result for registry, persistence, and UI consumers. + #[cfg(feature = "observe")] + pub fn from_cascade(result: &temper_verify::CascadeResult, verified_at: String) -> Self { + let levels = result + .levels + .iter() + .map(|level| { + let mut details = Vec::new(); + if !level.passed { + if let Some(simulation) = &level.simulation { + for violation in &simulation.liveness_violations { + details.push(VerificationDetail { + kind: "liveness_violation".into(), + property: violation.property.clone(), + description: violation.description.clone(), + actor_id: Some(violation.actor_id.clone()), + }); + } + for violation in &simulation.violations { + details.push(VerificationDetail { + kind: "invariant_violation".into(), + property: violation.invariant.clone(), + description: format!( + "Actor {} violated invariant at tick {} during action {}", + violation.actor_id, violation.tick, violation.action + ), + actor_id: Some(violation.actor_id.clone()), + }); + } + } + if let Some(verification) = &level.verification { + for counterexample in &verification.counterexamples { + details.push(VerificationDetail { + kind: "counterexample".into(), + property: counterexample.property.clone(), + description: format!( + "Counterexample found with {} step trace", + counterexample.trace.len() + ), + actor_id: None, + }); + } + for transition in &verification.dead_transitions { + details.push(VerificationDetail { + kind: "dead_transition".into(), + property: transition.clone(), + description: "Transition is unreachable in model check".into(), + actor_id: None, + }); + } + } + if let Some(prop_test) = &level.prop_test + && let Some(failure) = &prop_test.failure + { + details.push(VerificationDetail { + kind: "proptest_failure".into(), + property: failure.invariant.clone(), + description: format!( + "Property test failed after sequence: {}", + failure.action_sequence.join(" -> ") + ), + actor_id: None, + }); + } + } + + EntityLevelSummary { + level: level.level.to_string(), + passed: level.passed, + summary: level.summary.clone(), + details: (!details.is_empty()).then_some(details), + } + }) + .collect(); + + Self { + all_passed: result.all_passed, + levels, + warnings: result.warnings.clone(), + errors: result + .errors + .iter() + .map(|error| VerificationCapabilityError { + code: error.code.clone(), + invariant: error.invariant.clone(), + assertion: error.assertion.clone(), + source_span: error.source_span, + }) + .collect(), + verified_at, + } + } +} + +/// Structured safety-capability error retained in deployment status records. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct VerificationCapabilityError { + /// Stable machine-readable diagnostic code. + pub code: String, + /// Declared invariant name. + pub invariant: String, + /// Unsupported assertion expression. + pub assertion: String, + /// Exact half-open assertion source span. + pub source_span: temper_spec::automaton::SourceSpan, +} + /// Summary of a single verification level. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct EntityLevelSummary { @@ -87,6 +201,15 @@ pub enum RegistryError { entity_type: String, source: String, }, + /// An IOA declares safety invariants outside the supported contract. + UnsupportedSafetyInvariants { + tenant: String, + entity_type: String, + invariants: Vec, + }, + /// A hot swap attempted to change a runtime or model-proved safety contract + /// without a state migration. + RuntimeInvariantMigrationRequired { tenant: String, entity_type: String }, } impl std::fmt::Display for RegistryError { @@ -108,6 +231,22 @@ impl std::fmt::Display for RegistryError { "failed to parse IOA for tenant '{tenant}', entity '{entity_type}': {source}" ) } + Self::UnsupportedSafetyInvariants { + tenant, + entity_type, + invariants, + } => write!( + f, + "cannot activate IOA for tenant '{tenant}', entity '{entity_type}': unsupported safety invariants: {}", + invariants.join(", ") + ), + Self::RuntimeInvariantMigrationRequired { + tenant, + entity_type, + } => write!( + f, + "cannot hot-swap runtime or model-proved safety contract for tenant '{tenant}', entity '{entity_type}' without validating and migrating existing durable state" + ), } } } @@ -279,6 +418,8 @@ mod tests { summary: "All checks passed".into(), details: None, }], + warnings: vec!["runtime disclosure".into()], + errors: Vec::new(), verified_at: "2025-01-01T00:00:00Z".into(), }; let json = serde_json::to_string(&result).unwrap(); @@ -286,6 +427,38 @@ mod tests { assert!(back.all_passed); assert_eq!(back.levels.len(), 1); assert!(back.levels[0].details.is_none()); + assert_eq!(back.warnings, vec!["runtime disclosure"]); + assert!(back.errors.is_empty()); + } + + #[cfg(feature = "observe")] + #[test] + fn cascade_conversion_retains_capability_errors_and_disclosures() { + let source = r#" +[automaton] +name = "Quota" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[invariant]] +name = "UnsupportedQuota" +when = ["Active"] +assert = "used_bytes ** quota_limit" +"#; + let mut cascade = temper_verify::VerificationCascade::from_ioa(source).run(); + cascade.warnings.push("runtime-only disclosure".to_string()); + + let result = + EntityVerificationResult::from_cascade(&cascade, "2026-07-20T00:00:00Z".to_string()); + + assert!(!result.all_passed); + assert_eq!(result.warnings, vec!["runtime-only disclosure"]); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].code, "TVE001"); + assert_eq!(result.errors[0].invariant, "UnsupportedQuota"); + assert_eq!(result.errors[0].assertion, "used_bytes ** quota_limit"); + assert!(result.levels.is_empty()); } #[test] diff --git a/crates/temper-server/src/registry/unsupported_safety_tests.rs b/crates/temper-server/src/registry/unsupported_safety_tests.rs new file mode 100644 index 000000000..b21e417a0 --- /dev/null +++ b/crates/temper-server/src/registry/unsupported_safety_tests.rs @@ -0,0 +1,64 @@ +use super::*; +use temper_spec::csdl::parse_csdl; + +const CSDL_XML: &str = include_str!("../../../../test-fixtures/specs/model.csdl.xml"); +const ORDER_IOA: &str = include_str!("../../../../test-fixtures/specs/order.ioa.toml"); + +fn minimal_csdl() -> (CsdlDocument, String) { + let doc = parse_csdl(CSDL_XML).expect("CSDL should parse"); + (doc, CSDL_XML.to_string()) +} + +fn unsupported_order_ioa() -> String { + format!( + r#"{ORDER_IOA} + +[[invariant]] +name = "UnsupportedRegistrySafety" +when = ["Draft"] +assert = "ghost ** quota" +"# + ) +} + +#[test] +fn first_registration_rejects_unsupported_safety_invariants_atomically() { + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + let unsupported = unsupported_order_ioa(); + + let error = registry + .try_register_tenant("alpha", csdl, xml, &[("Order", &unsupported)]) + .expect_err("unsupported safety must not become live"); + + assert!(matches!( + error, + RegistryError::UnsupportedSafetyInvariants { .. } + )); + assert!(registry.get_tenant(&TenantId::new("alpha")).is_none()); +} + +#[test] +fn hot_swap_rejects_unsupported_safety_before_mutating_live_spec() { + let mut registry = SpecRegistry::new(); + let (csdl, xml) = minimal_csdl(); + registry.register_tenant("alpha", csdl, xml, &[("Order", ORDER_IOA)]); + let unsupported = unsupported_order_ioa(); + let (replacement_csdl, replacement_xml) = minimal_csdl(); + + let error = registry + .try_register_tenant( + "alpha", + replacement_csdl, + replacement_xml, + &[("Order", &unsupported)], + ) + .expect_err("unsupported hot swap must be rejected"); + + assert!(matches!( + error, + RegistryError::UnsupportedSafetyInvariants { .. } + )); + let current = registry.get_tenant(&TenantId::new("alpha")).unwrap(); + assert_eq!(current.entities["Order"].ioa_source, ORDER_IOA); +} diff --git a/crates/temper-server/src/registry_bootstrap.rs b/crates/temper-server/src/registry_bootstrap.rs index 9764fa70e..792e505c9 100644 --- a/crates/temper-server/src/registry_bootstrap.rs +++ b/crates/temper-server/src/registry_bootstrap.rs @@ -127,6 +127,8 @@ fn row_to_registry_status(row: &impl SpecRowLike) -> VerificationStatus { VerificationStatus::Restored(EntityVerificationResult { all_passed, levels, + warnings: Vec::new(), + errors: Vec::new(), verified_at: row.updated_at_rfc3339(), }) } diff --git a/crates/temper-server/src/registry_bootstrap_test.rs b/crates/temper-server/src/registry_bootstrap_test.rs index dc0e73056..04d3690bd 100644 --- a/crates/temper-server/src/registry_bootstrap_test.rs +++ b/crates/temper-server/src/registry_bootstrap_test.rs @@ -129,6 +129,42 @@ fn populate_registry_merges_csdl_fragments_from_all_restored_rows() { ); } +#[test] +fn populate_registry_rejects_restored_unsupported_safety_invariant() { + let unsupported = format!( + r#"{} + +[[invariant]] +name = "UnsupportedRestoredSafety" +when = ["Draft"] +assert = "ghost ** quota" +"#, + include_str!("../../../test-fixtures/specs/order.ioa.toml") + ); + let rows = vec![MockSpecRow { + entity_type: "Order".to_string(), + ioa_source: unsupported, + csdl_xml: csdl_xml_for("Order", "Orders"), + status: "passed".to_string(), + verified: true, + }]; + let mut grouped = BTreeMap::new(); + grouped.insert("default".to_string(), rows); + let mut registry = SpecRegistry::new(); + + let error = populate_registry( + &mut registry, + grouped, + &mut BTreeMap::new(), + |row| Some(row.csdl_xml.clone()), + |row| (row.entity_type.clone(), row.ioa_source.clone()), + ) + .expect_err("unsupported restored safety must not activate"); + + assert!(error.contains("UnsupportedRestoredSafety")); + assert!(registry.get_tenant(&TenantId::default()).is_none()); +} + #[test] fn row_to_registry_status_pending() { let status = row_to_registry_status(&MockRow { diff --git a/crates/temper-server/src/router_test.rs b/crates/temper-server/src/router_test.rs index c9b7728a6..53c480a6b 100644 --- a/crates/temper-server/src/router_test.rs +++ b/crates/temper-server/src/router_test.rs @@ -2,8 +2,14 @@ use super::*; use axum::body::Body; use axum::http::{Request, StatusCode}; use temper_runtime::ActorSystem; +#[cfg(feature = "sim")] +use temper_runtime::persistence::{EventMetadata, EventStore, PersistenceEnvelope}; +#[cfg(feature = "sim")] +use temper_runtime::scheduler::{install_deterministic_context, sim_now, sim_uuid}; use temper_runtime::tenant::TenantId; use temper_spec::csdl::parse_csdl; +#[cfg(feature = "sim")] +use temper_store_sim::SimEventStore; use temper_store_turso::TursoEventStore; use tower::ServiceExt; @@ -464,6 +470,11 @@ initial = "Recorded" name = "Body" type = "string" initial = "" + +[[invariant]] +name = "BodyRequired" +when = ["Recorded"] +assert = "Body != ''" "#; let csdl = parse_csdl(csdl_xml).unwrap(); let system = ActorSystem::new("test-data-only-fast-path"); @@ -707,6 +718,144 @@ async fn test_data_only_create_fast_path_declines_action_bearing_entities() { ); } +#[cfg(feature = "sim")] +#[tokio::test] +async fn failed_lazy_hydration_stops_and_evicts_the_spawned_actor() { + let (_guard, _clock, _id_gen) = install_deterministic_context(213); + let csdl_xml = r#" + + + + + + + + + + + + + + +"#; + let ioa = r#" +[automaton] +name = "ToolCall" +states = ["Pending"] +initial = "Pending" +allow_indefinite_states = ["Pending"] + +[[state]] +name = "agent_id" +type = "string" +initial = "" + +[[action]] +name = "Initialize" +kind = "input" +from = ["Pending"] +params = ["agent_id"] + +[[invariant]] +name = "RequiresAgentId" +when = ["Pending"] +assert = "agent_id != ''" +"#; + let store = SimEventStore::no_faults(213); + let persistence_id = "default:ToolCall:invalid-hydration"; + store + .append( + persistence_id, + 0, + &[PersistenceEnvelope { + sequence_nr: 0, + event_type: "Initialize".to_string(), + payload: serde_json::json!({ + "action": "Initialize", + "from_status": "Pending", + "to_status": "Pending", + "timestamp": "2024-01-01T00:00:00Z", + "params": {"agent_id": ""} + }), + metadata: EventMetadata { + event_id: sim_uuid(), + causation_id: sim_uuid(), + correlation_id: sim_uuid(), + timestamp: sim_now(), + actor_id: persistence_id.to_string(), + }, + }], + ) + .await + .expect("seed invalid durable history"); + + let mut specs = std::collections::BTreeMap::new(); + specs.insert("ToolCall".to_string(), ioa.to_string()); + let state = ServerState::with_storage_stack( + ActorSystem::new("failed-hydration-eviction"), + parse_csdl(csdl_xml).expect("parse hydration test CSDL"), + csdl_xml.to_string(), + specs, + StorageStack::from_sim(store, None), + ) + .expect("build hydration test state"); + let tenant = TenantId::default(); + + assert!( + !state + .ensure_entity_loaded(&tenant, "ToolCall", "invalid-hydration") + .await, + "runtime-invalid durable history must fail hydration" + ); + assert!( + !state + .actor_registry + .read() + .unwrap() + .contains_key(persistence_id), + "failed hydration must not orphan the spawned actor" + ); + assert!(!state.entity_exists(&tenant, "ToolCall", "invalid-hydration")); +} + +#[tokio::test] +async fn data_only_create_rejects_invalid_runtime_state_before_journal_or_projection_write() { + let state = test_state_with_data_only_ioa_and_turso().await; + let entity_id = "entry-invalid-runtime"; + + let error = state + .try_create_data_only_tenant_entity( + &TenantId::default(), + "LogEntry", + entity_id, + serde_json::json!({"Id": entity_id, "Body": ""}), + ) + .await + .expect_err("empty body must fail the data-only runtime safety gate"); + assert!(error.contains("BodyRequired"), "unexpected error: {error}"); + + let (journal, _) = state.event_journal().expect("turso event journal"); + assert!( + journal + .read_events(&format!("default:LogEntry:{entity_id}"), 0) + .await + .expect("read rejected journal") + .is_empty(), + "rejected data-only creation must not append Created" + ); + + let query_plane = state.query_plane_store().expect("turso query plane"); + let rows = query_plane + .load_entity_catalog_rows("default", "LogEntry", &[entity_id.to_string()]) + .await + .expect("read rejected projection") + .expect("turso supports catalog reads"); + assert!( + rows.is_empty(), + "rejected data-only creation must not publish a projection row" + ); +} + #[tokio::test] async fn commons_rate_limit_returns_429_per_owner_bucket() { let state = test_state_with_rate_limit_ioa(); diff --git a/crates/temper-server/src/state/dispatch/actions.rs b/crates/temper-server/src/state/dispatch/actions.rs index 20954e510..b7c0a3a56 100644 --- a/crates/temper-server/src/state/dispatch/actions.rs +++ b/crates/temper-server/src/state/dispatch/actions.rs @@ -686,6 +686,16 @@ impl crate::state::ServerState { } }; + // The actor response is the durable mutation boundary. Publish the + // committed state before fallible post-dispatch effects so an effect + // failure cannot leave durable events absent from discovery indexes. + if let Some(visible_status) = self + .visible_status_after_response(&actor_ref, &response) + .await + { + self.update_entity_index_visibility(tenant, entity_type, entity_id, &visible_status); + } + // Run all post-dispatch effects through the dedicated pipeline. let ctx = PostDispatchContext { tenant, @@ -697,7 +707,11 @@ impl crate::state::ServerState { action_params: &action_params, await_integration, }; - let response = self.run_post_dispatch_effects(&ctx, response).await; + // Post-dispatch integrations can synchronously dispatch a callback. + // Keep that nested future on the heap so blocking WASM callbacks do + // not accumulate the full dispatch state machine on the Tokio worker + // stack. + let response = Box::pin(self.run_post_dispatch_effects(&ctx, response)).await; if response.success && let Some(ref idem_key) = idempotency_key { diff --git a/crates/temper-server/src/state/entity_ops.rs b/crates/temper-server/src/state/entity_ops.rs index 633df47b1..10e9c9b89 100644 --- a/crates/temper-server/src/state/entity_ops.rs +++ b/crates/temper-server/src/state/entity_ops.rs @@ -13,7 +13,7 @@ use temper_runtime::tenant::TenantId; use super::dispatch::retry; use super::{ServerState, projection_backfill}; -use crate::entity_actor::{EntityActor, EntityEvent, EntityMsg, EntityResponse, EntityState}; +use crate::entity_actor::{EntityActor, EntityEvent, EntityMsg, EntityResponse}; use crate::events::EntityStateChange; use crate::registry::{VerificationDetail, VerificationStatus}; use crate::runtime_metrics; @@ -757,19 +757,59 @@ impl ServerState { actor_ref }; - // Track in entity index for collection queries - { - let index_key = format!("{tenant}:{entity_type}"); - let mut index = self.entity_index.write().unwrap(); + self.touch_actor_access(&key); + runtime_metrics::record_server_state_metrics(self); + + Some(actor_ref) + } + + /// Publish a validated entity state to collection discovery, or remove a + /// tombstone. Actor spawn alone is deliberately not a publication event: + /// action-backed initialization may require a transient pristine actor. + pub(crate) fn update_entity_index_visibility( + &self, + tenant: &TenantId, + entity_type: &str, + entity_id: &str, + status: &str, + ) { + let index_key = format!("{tenant}:{entity_type}"); + let mut index = self.entity_index.write().unwrap(); + if status == "Deleted" { + if let Some(ids) = index.get_mut(&index_key) { + ids.remove(entity_id); + } + } else { index .entry(index_key) .or_default() .insert(entity_id.to_string()); } - self.touch_actor_access(&key); - runtime_metrics::record_server_state_metrics(self); + } - Some(actor_ref) + /// Resolve the collection-visible status at an actor response boundary. + /// + /// A rejected first mutation can still leave a valid pristine entity (for + /// example, a no-journal actor whose initial state already satisfies every + /// runtime invariant). Re-read only that zero-event rejection so invalid + /// action-backed shells stay hidden while valid bootstrap state remains + /// discoverable. + pub(crate) async fn visible_status_after_response( + &self, + actor_ref: &ActorRef, + response: &EntityResponse, + ) -> Option { + if response.success || response.state.sequence_nr > 0 { + return Some(response.state.status.clone()); + } + + let policy = self.dispatch_retry_policy(); + retry::ask_with_backoff::<_, EntityResponse, _>(actor_ref, || EntityMsg::GetState, &policy) + .await + .result + .ok() + .filter(|state| state.success) + .map(|state| state.state.status) } /// Remove an entity from the index and actor registry. @@ -971,6 +1011,12 @@ impl ServerState { .result .map_err(|e| format!("Actor query failed: {e}"))?; + if !response.success { + return Ok(response); + } + + self.update_entity_index_visibility(tenant, entity_type, entity_id, &response.state.status); + // Broadcast entity creation event for SSE subscribers let seq = self.next_entity_event_sequence(tenant.as_str(), entity_type, entity_id); let change = EntityStateChange { @@ -1103,31 +1149,19 @@ impl ServerState { }; let persistence_id = format!("{tenant}:{entity_type}:{entity_id}"); - let mut fields = initial_fields.clone(); - if let Some(obj) = fields.as_object_mut() { - obj.entry("Id".to_string()) - .or_insert(serde_json::Value::String(entity_id.to_string())); - obj.entry("Status".to_string()) - .or_insert(serde_json::Value::String(table.initial_state.clone())); + let mut state = crate::entity_actor::effects::build_initial_entity_state( + entity_type, + entity_id, + &table, + &initial_fields, + )?; + if let Some(error) = crate::entity_actor::effects::runtime_invariant_failure(&state, &table) + { + return Err(format!( + "initial data-only entity violates runtime safety contract: {error}" + )); } - let mut state = EntityState { - entity_type: entity_type.to_string(), - entity_id: entity_id.to_string(), - status: table.initial_state.clone(), - item_count: 0, - counters: BTreeMap::new(), - booleans: BTreeMap::new(), - lists: BTreeMap::new(), - fields, - events: std::collections::VecDeque::new(), - total_event_count: 0, - events_since_snapshot: 0, - last_snapshot_sequence_nr: 0, - sequence_nr: 0, - processed_idempotency_keys: BTreeMap::new(), - }; - let created = EntityEvent { action: "Created".to_string(), from_status: String::new(), @@ -1365,6 +1399,13 @@ impl ServerState { .result .map_err(|e| format!("Actor update failed: {e}"))?; + if let Some(visible_status) = self + .visible_status_after_response(&actor_ref, &response) + .await + { + self.update_entity_index_visibility(tenant, entity_type, entity_id, &visible_status); + } + if response.success && let Some(query_plane) = self.query_plane_store() { @@ -1560,13 +1601,24 @@ impl ServerState { .await; match outcome.result { Ok(response) if response.state.status == "Deleted" => { - let _ = actor_ref.stop(); - self.remove_entity(tenant, entity_type, entity_id); + self.stop_and_remove_entity(tenant, entity_type, entity_id); + false + } + Ok(response) if response.success => { + self.update_entity_index_visibility( + tenant, + entity_type, + entity_id, + &response.state.status, + ); + true + } + Ok(_) => { + self.stop_and_remove_entity(tenant, entity_type, entity_id); false } - Ok(_) => true, Err(_) => { - self.remove_entity(tenant, entity_type, entity_id); + self.stop_and_remove_entity(tenant, entity_type, entity_id); false } } @@ -1607,9 +1659,14 @@ impl ServerState { /// Passivate actors that have been idle longer than the configured timeout. /// - /// Keeps `entity_index` entries intact so future accesses can lazy-spawn. + /// Keeps `entity_index` entries intact so durable actors can lazy-spawn. + /// In-memory-only actors cannot be reconstructed and therefore remain live. #[instrument(skip_all, fields(otel.name = "entity.passivate_idle_actors"))] pub async fn passivate_idle_actors(&self) { + let Some((_store, _backend)) = self.event_journal() else { + return; + }; + let timeout_secs = actor_idle_timeout_secs(); let cutoff = sim_now() - chrono::Duration::seconds(timeout_secs); @@ -1639,48 +1696,34 @@ impl ServerState { let mut passivated = 0usize; let policy = self.dispatch_retry_policy(); - let journal = self.event_journal(); for (actor_key, actor_ref) in candidates { // ADR-0048: retry transient failures so passivation is not skipped // by a single AskTimeout under load. - let snapshot_outcome = retry::ask_with_backoff::<_, EntityResponse, _>( + let passivation_outcome = retry::ask_with_backoff::<_, EntityResponse, _>( &actor_ref, - || EntityMsg::GetState, + || EntityMsg::Passivate, &policy, ) .await; - if let Some((store, _backend)) = journal.as_ref() - && let Ok(response) = snapshot_outcome.result - && response.state.sequence_nr > 0 - { - // Snapshot excludes bounded in-memory recent event history. - let mut snapshot_value = match serde_json::to_value(&response.state) { - Ok(v) => v, - Err(e) => { - tracing::warn!(actor_key = %actor_key, error = %e, "failed to encode snapshot value"); - serde_json::Value::Null - } - }; - if let Some(obj) = snapshot_value.as_object_mut() { - obj.remove("events"); - } - if !snapshot_value.is_null() - && let Ok(snapshot_bytes) = serde_json::to_vec(&snapshot_value) - && let Err(e) = store - .save_snapshot(&actor_key, response.state.sequence_nr, &snapshot_bytes) - .await - { + match passivation_outcome.result { + Ok(response) if response.success => {} + Ok(response) => { tracing::warn!( actor_key = %actor_key, - seq = response.state.sequence_nr, - error = %e, - "failed to save snapshot during passivation" + error = ?response.error, + "actor declined passivation; retaining actor" ); + continue; + } + Err(error) => { + tracing::warn!(actor_key = %actor_key, %error, "failed to passivate actor; retaining actor"); + continue; } } - let _ = actor_ref.stop(); - + // The actor stopped itself immediately after replying, before its + // mailbox could admit another mutation. Removing only the same UID + // avoids racing a concurrent lazy respawn. let removed = { let Ok(mut registry) = self.actor_registry.write() else { continue; @@ -1696,16 +1739,18 @@ impl ServerState { } }; - if removed { - if let Ok(mut last_accessed) = self.last_accessed.write() { - last_accessed.remove(&actor_key); - } - // Evict the state cache entry so stale status doesn't linger. - if let Ok(mut cache) = self.entity_state_cache.lock() { - cache.pop(&actor_key); - } - passivated += 1; + if !removed { + continue; + } + + if let Ok(mut last_accessed) = self.last_accessed.write() { + last_accessed.remove(&actor_key); + } + // Evict the state cache entry so stale status doesn't linger. + if let Ok(mut cache) = self.entity_state_cache.lock() { + cache.pop(&actor_key); } + passivated += 1; } if passivated > 0 { diff --git a/crates/temper-server/src/state/mod.rs b/crates/temper-server/src/state/mod.rs index e9cd9796d..d7b4bfa82 100644 --- a/crates/temper-server/src/state/mod.rs +++ b/crates/temper-server/src/state/mod.rs @@ -848,8 +848,17 @@ impl ServerState { let mut state = Self::new(system, csdl, csdl_xml); let mut tables = BTreeMap::new(); for (entity_type, ioa_source) in &ioa_sources { - let table = TransitionTable::try_from_ioa_source(ioa_source) + let automaton = temper_spec::automaton::parse_automaton(ioa_source) .map_err(|e| format!("entity '{entity_type}': {e}"))?; + let unsupported = + temper_spec::automaton::unsupported_safety_invariant_names(&automaton); + if !unsupported.is_empty() { + return Err(format!( + "entity '{entity_type}': unsupported safety invariants: {}", + unsupported.join(", ") + )); + } + let table = TransitionTable::from_automaton(&automaton); tables.insert(entity_type.clone(), Arc::new(table)); } state.transition_tables = Arc::new(tables); @@ -1435,7 +1444,73 @@ impl ServerState { #[cfg(test)] mod tests { - use super::normalize_local_tdata_host; + use super::{ServerState, normalize_local_tdata_host}; + use temper_runtime::ActorSystem; + use temper_spec::csdl::parse_csdl; + + const UNSUPPORTED_IOA: &str = r#" +[automaton] +name = "Order" +states = ["Draft"] +initial = "Draft" +allow_indefinite_states = ["Draft"] + +[[invariant]] +name = "UnsupportedLegacySafety" +when = ["Draft"] +assert = "used_bytes ** quota_limit" +"#; + + fn legacy_constructor_inputs() -> ( + temper_spec::csdl::CsdlDocument, + String, + std::collections::BTreeMap, + ) { + let xml = include_str!("../../../../test-fixtures/specs/model.csdl.xml"); + let csdl = parse_csdl(xml).expect("test CSDL"); + let specs = [("Order".to_string(), UNSUPPORTED_IOA.to_string())] + .into_iter() + .collect(); + (csdl, xml.to_string(), specs) + } + + #[test] + fn legacy_in_memory_constructor_rejects_unsupported_safety() { + let (csdl, xml, specs) = legacy_constructor_inputs(); + let error = match ServerState::with_specs( + ActorSystem::new("unsupported-legacy-memory"), + csdl, + xml, + specs, + ) { + Ok(_) => panic!("unsupported safety must not activate"), + Err(error) => error, + }; + assert!(error.contains("Order")); + assert!(error.contains("UnsupportedLegacySafety")); + } + + #[cfg(feature = "sim")] + #[test] + fn legacy_persisted_constructor_rejects_unsupported_safety() { + let (csdl, xml, specs) = legacy_constructor_inputs(); + let stack = crate::storage::StorageStack::from_sim( + temper_store_sim::SimEventStore::no_faults(213), + None, + ); + let error = match ServerState::with_storage_stack( + ActorSystem::new("unsupported-legacy-persisted"), + csdl, + xml, + specs, + stack, + ) { + Ok(_) => panic!("unsupported safety must not activate with persistence"), + Err(error) => error, + }; + assert!(error.contains("Order")); + assert!(error.contains("UnsupportedLegacySafety")); + } #[test] fn normalize_local_tdata_host_accepts_urls_domains_and_ports() { diff --git a/crates/temper-server/tests/dst_hotswap.rs b/crates/temper-server/tests/dst_hotswap.rs index 247f484b9..0b27066e3 100644 --- a/crates/temper-server/tests/dst_hotswap.rs +++ b/crates/temper-server/tests/dst_hotswap.rs @@ -6,74 +6,31 @@ mod common; -use temper_runtime::scheduler::install_deterministic_context; +use temper_runtime::scheduler::{install_deterministic_context, sim_now}; use temper_runtime::tenant::TenantId; use temper_spec::csdl::parse_csdl; +use temper_store_sim::{SimEventStore, SimFaultConfig}; -/// An extended Order spec with an additional "Archived" state and "ArchiveOrder" action. -const ORDER_V2_IOA: &str = r#" -[automaton] -name = "Order" -initial = "Draft" -states = ["Draft", "Submitted", "Confirmed", "Processing", "Shipped", "Delivered", "Cancelled", "Archived"] +const ORDER_IOA: &str = include_str!("../../../test-fixtures/specs/order.ioa.toml"); -[[action]] -name = "AddItem" -from = ["Draft"] -to = "Draft" -kind = "input" - -[[action.effect]] -type = "IncrementCounter" -var = "item_count" - -[[action]] -name = "SubmitOrder" -from = ["Draft"] -to = "Submitted" -kind = "input" - -[[action.guard]] -type = "CounterMin" -var = "item_count" -min = 1 - -[[action]] -name = "ConfirmOrder" -from = ["Submitted"] -to = "Confirmed" -kind = "internal" - -[[action]] -name = "ProcessOrder" -from = ["Confirmed"] -to = "Processing" -kind = "internal" - -[[action]] -name = "ShipOrder" -from = ["Processing"] -to = "Shipped" -kind = "internal" - -[[action]] -name = "DeliverOrder" -from = ["Shipped"] -to = "Delivered" -kind = "internal" - -[[action]] -name = "CancelOrder" -from = ["Draft", "Submitted", "Confirmed", "Processing"] -to = "Cancelled" -kind = "input" +/// Extend the current fixture without removing or changing its verified safety model. +fn order_v2_ioa() -> String { + let extended_states = ORDER_IOA.replacen( + "\"ReturnRequested\", \"Returned\", \"Refunded\"]", + "\"ReturnRequested\", \"Returned\", \"Refunded\", \"Archived\"]", + 1, + ); + format!( + r#"{extended_states} [[action]] name = "ArchiveOrder" -from = ["Delivered", "Cancelled"] +from = ["Delivered"] to = "Archived" kind = "input" -"#; +"# + ) +} // ========================================================================= // Test: Hot-swap adds new states visible to live entities @@ -125,11 +82,12 @@ async fn dst_hotswap_entity_sees_new_table() { { let mut reg = state.registry.write().expect("registry lock"); // ci-ok: infallible lock let csdl = parse_csdl(common::CSDL_XML).expect("CSDL parse"); + let order_v2 = order_v2_ioa(); reg.register_tenant( "default", csdl, common::CSDL_XML.to_string(), - &[("Order", ORDER_V2_IOA)], + &[("Order", &order_v2)], ); } @@ -189,11 +147,12 @@ async fn dst_hotswap_version_increases() { { let mut reg = state.registry.write().expect("registry lock"); // ci-ok: infallible lock let csdl = parse_csdl(common::CSDL_XML).expect("CSDL parse"); + let order_v2 = order_v2_ioa(); reg.register_tenant( "default", csdl, common::CSDL_XML.to_string(), - &[("Order", ORDER_V2_IOA)], + &[("Order", &order_v2)], ); } @@ -208,3 +167,137 @@ async fn dst_hotswap_version_increases() { "version should increase after hot-swap: v1={v1}, v2={v2}" ); } + +#[tokio::test] +async fn dst_hotswap_replays_pre_and_post_swap_journal_after_restart() { + for seed in 0..20 { + let shared_store = SimEventStore::no_faults(seed); + let tenant = TenantId::default(); + let entity_id = format!("o-replay-{seed}"); + + { + let (_guard, _clock, _id_gen) = install_deterministic_context(seed); + let state = common::build_default_state_with_store( + shared_store.clone(), + "dst-hotswap-replay-before", + ); + for action in &["AddItem", "SubmitOrder", "ConfirmOrder"] { + let result = common::dispatch( + &state, + &tenant, + "Order", + &entity_id, + action, + serde_json::json!({}), + ) + .await + .unwrap_or_else(|error| panic!("seed {seed}: {action} failed: {error}")); + assert!(result.success, "seed {seed}: {action}: {:?}", result.error); + } + + register_order_v2(&state); + for action in &["ProcessOrder", "ShipOrder", "DeliverOrder", "ArchiveOrder"] { + let result = common::dispatch( + &state, + &tenant, + "Order", + &entity_id, + action, + serde_json::json!({}), + ) + .await + .unwrap_or_else(|error| panic!("seed {seed}: {action} failed: {error}")); + assert!(result.success, "seed {seed}: {action}: {:?}", result.error); + } + } + + let (_guard, _clock, _id_gen) = install_deterministic_context(seed + 10_000); + let restarted = + common::build_default_state_with_store(shared_store, "dst-hotswap-replay-after"); + register_order_v2(&restarted); + let recovered = restarted + .get_tenant_entity_state(&tenant, "Order", &entity_id) + .await + .unwrap_or_else(|error| panic!("seed {seed}: replay failed: {error}")); + assert_eq!(recovered.state.status, "Archived", "seed {seed}"); + assert_eq!(recovered.state.item_count, 1, "seed {seed}"); + } +} + +#[tokio::test] +async fn dst_hotswap_snapshot_fault_retains_live_post_swap_state() { + let seed = 213; + let (_guard, _clock, _id_gen) = install_deterministic_context(seed); + let sim_store = SimEventStore::new( + seed, + SimFaultConfig { + snapshot_failure_prob: 1.0, + ..SimFaultConfig::none() + }, + ); + let state = common::build_default_state_with_store(sim_store, "dst-hotswap-snapshot-failure"); + let tenant = TenantId::default(); + let entity_id = format!("o-snapshot-failure-{seed}"); + + for action in &["AddItem", "SubmitOrder", "ConfirmOrder"] { + let result = common::dispatch( + &state, + &tenant, + "Order", + &entity_id, + action, + serde_json::json!({}), + ) + .await + .expect("pre-swap dispatch"); + assert!(result.success, "{action}: {:?}", result.error); + } + register_order_v2(&state); + for action in &["ProcessOrder", "ShipOrder", "DeliverOrder", "ArchiveOrder"] { + let result = common::dispatch( + &state, + &tenant, + "Order", + &entity_id, + action, + serde_json::json!({}), + ) + .await + .expect("post-swap dispatch"); + assert!(result.success, "{action}: {:?}", result.error); + } + + let actor_key = format!("{tenant}:Order:{entity_id}"); + state.last_accessed.write().unwrap().insert( + actor_key.clone(), + sim_now() - chrono::Duration::seconds(600), + ); + state.passivate_idle_actors().await; + + assert!( + state + .actor_registry + .read() + .unwrap() + .contains_key(&actor_key), + "snapshot failure must retain the live actor with acknowledged post-swap state" + ); + let current = state + .get_tenant_entity_state(&tenant, "Order", &entity_id) + .await + .expect("retained actor state"); + assert_eq!(current.state.status, "Archived"); + assert_eq!(current.state.item_count, 1); +} + +fn register_order_v2(state: &temper_server::ServerState) { + let mut registry = state.registry.write().expect("registry lock"); // ci-ok: infallible lock + let csdl = parse_csdl(common::CSDL_XML).expect("CSDL parse"); + let order_v2 = order_v2_ioa(); + registry.register_tenant( + "default", + csdl, + common::CSDL_XML.to_string(), + &[("Order", &order_v2)], + ); +} diff --git a/crates/temper-server/tests/e2e_gepa_loop.rs b/crates/temper-server/tests/e2e_gepa_loop.rs index d7f97ed28..5d3bca832 100644 --- a/crates/temper-server/tests/e2e_gepa_loop.rs +++ b/crates/temper-server/tests/e2e_gepa_loop.rs @@ -870,53 +870,52 @@ async fn e2e_gepa_algorithm_primitives_integrated() { } // ========================================================================= -// Phase 6: Hot-deploy mutated spec and verify Reassign works +// Phase 6: Hot-deploy mutated spec and verify a new action works // ========================================================================= -/// Proves: after hot-deploying a mutated Issue spec (with Reassign action), -/// the previously-failing Reassign action now succeeds through the platform. +/// Proves: after hot-deploying a mutated Issue spec with a new action, +/// the previously-unknown action succeeds through the platform. #[tokio::test] async fn e2e_gepa_hotdeploy_and_verify() { let (_guard, _clock, _id_gen) = install_deterministic_context(46); let harness = SimPlatformHarness::no_faults(46); - // Install PM skill (Issue spec WITHOUT Reassign). + // Install PM skill (Issue spec WITHOUT TransferAssignment). harness .install_app(TENANT, "project-management") .await .expect("PM skill should install"); - // Verify Reassign fails on a fresh Issue. + // Verify TransferAssignment fails on a fresh Issue. let r = harness .dispatch( TENANT, "Issue", "issue-hotdeploy-1", - "Reassign", + "TransferAssignment", serde_json::json!({"NewAssigneeId": "agent-2"}), ) .await; if let Ok(resp) = &r { assert!( !resp.success, - "Reassign should fail before hot-deploy: {:?}", + "TransferAssignment should fail before hot-deploy: {:?}", resp.error ); } - // Now create a mutated Issue spec that adds Reassign. - // We take the original and add a Reassign action. + // Now create a mutated Issue spec that adds TransferAssignment. let mutated_issue_spec = include_str!("../../../os-apps/project-management/specs/issue.ioa.toml").to_string() + r#" [[action]] -name = "Reassign" +name = "TransferAssignment" kind = "input" from = ["Backlog", "Triage", "Todo", "InProgress", "InReview", "Planning", "Planned"] guard = "is_true assignee_set" params = ["NewAssigneeId"] -hint = "Reassign the issue to a different implementer." +hint = "Transfer the issue to a different implementer." "#; // Verify the mutated spec parses (L0 check). @@ -952,7 +951,7 @@ hint = "Reassign the issue to a different implementer." .expect("hot-deploy should succeed"); } - // Now Reassign should work on an Issue that has an assignee set. + // Now TransferAssignment should work on an Issue that has an assignee set. // Create a fresh Issue (starts in Backlog), then Assign to set assignee_set=true. let r = harness .dispatch( @@ -966,26 +965,26 @@ hint = "Reassign the issue to a different implementer." .expect("Assign should succeed"); assert!(r.success, "Assign failed: {:?}", r.error); - // NOW: Reassign should succeed because the mutated spec has it + // NOW: TransferAssignment should succeed because the mutated spec has it // (self-loop on Backlog with guard is_true assignee_set). let r = harness .dispatch( TENANT, "Issue", "issue-hotdeploy-2", - "Reassign", + "TransferAssignment", serde_json::json!({"NewAssigneeId": "agent-2"}), ) .await - .expect("Reassign should succeed after hot-deploy"); + .expect("TransferAssignment should succeed after hot-deploy"); assert!( r.success, - "Reassign should succeed after hot-deploy: {:?}", + "TransferAssignment should succeed after hot-deploy: {:?}", r.error ); assert_eq!( r.state.status, "Backlog", - "Reassign is a self-loop, issue stays in Backlog" + "TransferAssignment is a self-loop, issue stays in Backlog" ); } @@ -1011,18 +1010,24 @@ async fn e2e_gepa_full_loop() { .expect("evolution skill should install"); harness.register_inline_spec(TENANT, "EvolutionRun", EVOLUTION_RUN_IOA_NO_INTEGRATIONS); - // --- Step 2: Simulate 6 Reassign failures --- + // --- Step 2: Simulate 6 TransferAssignment failures --- for i in 0..6 { let _r = harness .dispatch( TENANT, "Issue", &format!("loop-issue-{i}"), - "Reassign", + "TransferAssignment", serde_json::json!({"NewAssigneeId": "agent-x"}), ) .await; - // All should fail — Reassign doesn't exist. + assert!( + match &_r { + Ok(response) => !response.success, + Err(_) => true, + }, + "TransferAssignment must fail before hot-deploy" + ); } // --- Step 3: Sentinel detects the cluster --- @@ -1032,7 +1037,7 @@ async fn e2e_gepa_full_loop() { tenant: TENANT.to_string(), entity_type: "Issue".to_string(), entity_id: format!("loop-issue-{i}"), - action: "Reassign".to_string(), + action: "TransferAssignment".to_string(), success: false, from_status: Some("Backlog".to_string()), to_status: None, @@ -1084,7 +1089,7 @@ async fn e2e_gepa_full_loop() { "s1", "AlertsFound", serde_json::json!({ - "AlertDetails": "6 Reassign failures on Issue", + "AlertDetails": "6 TransferAssignment failures on Issue", "SuggestedTarget": "project-management/Issue" }), ) @@ -1130,7 +1135,7 @@ async fn e2e_gepa_full_loop() { ), ( "RecordMutation", - serde_json::json!({"MutatedSpecSource": "spec with Reassign", "MutationSummary": "Added Reassign"}), + serde_json::json!({"MutatedSpecSource": "spec with TransferAssignment", "MutationSummary": "Added TransferAssignment"}), ), ( "RecordVerificationPass", @@ -1173,12 +1178,12 @@ async fn e2e_gepa_full_loop() { + r#" [[action]] -name = "Reassign" +name = "TransferAssignment" kind = "input" from = ["Backlog", "Triage", "Todo", "InProgress", "InReview", "Planning", "Planned"] guard = "is_true assignee_set" params = ["NewAssigneeId"] -hint = "Reassign the issue to a different implementer." +hint = "Transfer the issue to a different implementer." "#; { @@ -1218,8 +1223,8 @@ hint = "Reassign the issue to a different implementer." assert!(r.success); assert_eq!(r.state.status, "Completed"); - // --- Step 7: Replay — Reassign now succeeds --- - // Create a fresh issue, Assign to set assignee_set=true, then Reassign. + // --- Step 7: Replay — TransferAssignment now succeeds --- + // Create a fresh issue, Assign to set assignee_set=true, then transfer it. let r = harness .dispatch( TENANT, @@ -1232,25 +1237,25 @@ hint = "Reassign the issue to a different implementer." .unwrap(); assert!(r.success, "Assign failed: {:?}", r.error); - // The moment of truth: Reassign should NOW succeed after evolution hot-deploy. + // The moment of truth: the new action succeeds after evolution hot-deploy. let r = harness .dispatch( TENANT, "Issue", "loop-retry-1", - "Reassign", + "TransferAssignment", serde_json::json!({"NewAssigneeId": "agent-2"}), ) .await - .expect("Reassign should succeed after evolution hot-deploy"); + .expect("TransferAssignment should succeed after evolution hot-deploy"); assert!( r.success, - "Reassign MUST succeed after GEPA evolution and hot-deploy: {:?}", + "TransferAssignment must succeed after GEPA evolution and hot-deploy: {:?}", r.error ); assert_eq!( r.state.status, "Backlog", - "Reassign self-loop keeps Backlog" + "TransferAssignment self-loop keeps Backlog" ); // --- Step 8: Verify GEPA primitives agree --- diff --git a/crates/temper-server/tests/field_invariants.rs b/crates/temper-server/tests/field_invariants.rs index a5f764718..57c6f2a5f 100644 --- a/crates/temper-server/tests/field_invariants.rs +++ b/crates/temper-server/tests/field_invariants.rs @@ -76,6 +76,8 @@ fn build_state_with_field_invariant() -> ServerState { summary: "OK".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-04-11T00:00:00Z".to_string(), }), ); diff --git a/crates/temper-server/tests/file_value_fast_path.rs b/crates/temper-server/tests/file_value_fast_path.rs index 165b29ed6..4d4622334 100644 --- a/crates/temper-server/tests/file_value_fast_path.rs +++ b/crates/temper-server/tests/file_value_fast_path.rs @@ -231,6 +231,8 @@ fn mark_file_verified(state: &ServerState) { summary: "test fixture verified".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-05-15T00:00:00Z".to_string(), }), ); @@ -391,6 +393,10 @@ async fn odata_file_value_put_applies_cedar_update_policy() { .get_or_create_tenant_entity(&tenant, "File", "fl-write-denied", serde_json::json!({})) .await .expect("create File state"); + let before = state + .get_tenant_entity_state(&tenant, "File", "fl-write-denied") + .await + .expect("read File state before denied update"); state .authz .reload_tenant_policies( @@ -416,7 +422,9 @@ async fn odata_file_value_put_applies_cedar_update_policy() { .get_tenant_entity_state(&tenant, "File", "fl-write-denied") .await .expect("File state should remain readable"); - assert!(entity.state.fields.get("content_hash").is_none()); + assert_eq!(entity.state.status, before.state.status); + assert_eq!(entity.state.sequence_nr, before.state.sequence_nr); + assert_eq!(entity.state.fields, before.state.fields); } #[tokio::test] diff --git a/crates/temper-server/tests/multi_tenant.rs b/crates/temper-server/tests/multi_tenant.rs index 18ee309d9..615aa8908 100644 --- a/crates/temper-server/tests/multi_tenant.rs +++ b/crates/temper-server/tests/multi_tenant.rs @@ -337,6 +337,8 @@ fn operations_allowed_after_verification_passes() { details: None, }, ], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-02-18T00:00:00Z".to_string(), }), ); @@ -378,6 +380,8 @@ async fn operations_blocked_after_verification_fails() { }]), }, ], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-02-18T00:00:00Z".to_string(), }), ); @@ -437,6 +441,8 @@ fn per_entity_gating_isolation() { summary: "OK".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-02-18T00:00:00Z".to_string(), }), ); diff --git a/crates/temper-server/tests/odata_read.rs b/crates/temper-server/tests/odata_read.rs index ff62320a2..0e8d1c2bf 100644 --- a/crates/temper-server/tests/odata_read.rs +++ b/crates/temper-server/tests/odata_read.rs @@ -159,6 +159,8 @@ fn build_turso_state(system_name: &str, store: TursoEventStore) -> ServerState { summary: "OK".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-04-15T00:00:00Z".to_string(), }), ); diff --git a/crates/temper-server/tests/passivation_respawn.rs b/crates/temper-server/tests/passivation_respawn.rs index dc5bacb08..81a307053 100644 --- a/crates/temper-server/tests/passivation_respawn.rs +++ b/crates/temper-server/tests/passivation_respawn.rs @@ -5,7 +5,7 @@ mod common; use temper_runtime::persistence::EventStore; use temper_runtime::scheduler::{install_deterministic_context, sim_now}; use temper_runtime::tenant::TenantId; -use temper_store_sim::SimEventStore; +use temper_store_sim::{SimEventStore, SimFaultConfig}; #[tokio::test] async fn passivated_actor_respawns_with_correct_state() { @@ -41,6 +41,17 @@ async fn passivated_actor_respawns_with_correct_state() { .expect("SubmitOrder should succeed"); assert!(r.success); assert_eq!(r.state.status, "Submitted"); + let updated = state + .update_tenant_entity_fields( + &tenant, + "Order", + &entity_id, + serde_json::json!({"note": "snapshotted"}), + false, + ) + .await + .expect("field update should succeed"); + assert!(updated.success); let actor_key = format!("{tenant}:Order:{entity_id}"); assert!( @@ -84,5 +95,73 @@ async fn passivated_actor_respawns_with_correct_state() { assert_eq!(recovered.state.status, "Submitted"); assert_eq!(recovered.state.item_count, 1); + assert_eq!(recovered.state.fields["note"], "snapshotted"); assert!(recovered.state.total_event_count >= 3); // Created + AddItem + SubmitOrder } + +#[tokio::test] +async fn snapshot_failure_retains_actor_and_acknowledged_field_state() { + let seed = 213; + let (_guard, _clock, _id_gen) = install_deterministic_context(seed); + let sim_store = SimEventStore::new( + seed, + SimFaultConfig { + snapshot_failure_prob: 1.0, + ..SimFaultConfig::none() + }, + ); + let state = common::build_default_state_with_store(sim_store.clone(), "passivation-failure"); + let tenant = TenantId::default(); + let entity_id = format!("o-snapshot-failure-{seed}"); + + let created = common::dispatch( + &state, + &tenant, + "Order", + &entity_id, + "AddItem", + serde_json::json!({}), + ) + .await + .expect("AddItem should succeed"); + assert!(created.success); + let updated = state + .update_tenant_entity_fields( + &tenant, + "Order", + &entity_id, + serde_json::json!({"note": "acknowledged"}), + false, + ) + .await + .expect("field update should succeed"); + assert!(updated.success); + + let actor_key = format!("{tenant}:Order:{entity_id}"); + state.last_accessed.write().unwrap().insert( + actor_key.clone(), + sim_now() - chrono::Duration::seconds(600), + ); + state.passivate_idle_actors().await; + + assert!( + state + .actor_registry + .read() + .unwrap() + .contains_key(&actor_key), + "failed snapshot must retain the only actor holding acknowledged state" + ); + assert!( + sim_store + .load_snapshot(&actor_key) + .await + .expect("snapshot lookup") + .is_none() + ); + let current = state + .get_tenant_entity_state(&tenant, "Order", &entity_id) + .await + .expect("retained actor state"); + assert_eq!(current.state.fields["note"], "acknowledged"); +} diff --git a/crates/temper-spec/src/automaton/assert_parser.rs b/crates/temper-spec/src/automaton/assert_parser.rs index 69e28dd53..5b0bfeccc 100644 --- a/crates/temper-spec/src/automaton/assert_parser.rs +++ b/crates/temper-spec/src/automaton/assert_parser.rs @@ -4,8 +4,10 @@ //! variants. Used by both `temper-verify` (model builder) and `temper-server` //! (simulation handler) to ensure consistent classification. +use std::collections::BTreeSet; + /// A comparison operator for counter assertions. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum AssertCompareOp { /// Greater than. Gt, @@ -22,6 +24,8 @@ pub enum AssertCompareOp { /// A parsed assertion expression from an IOA `[[invariant]]` section. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParsedAssert { + /// An assertion that is unconditionally true (`true`). + Always, /// A counter variable must be positive (`var > 0`). CounterPositive { var: String }, /// The entity is in a terminal state — no further transitions allowed. @@ -38,6 +42,14 @@ pub enum ParsedAssert { op: AssertCompareOp, value: usize, }, + /// One counter must satisfy a comparison against another counter. + CounterVarCompare { + left: String, + op: AssertCompareOp, + right: String, + }, + /// A declared string field must contain at least one character (`var != ''`). + StringNonEmpty { var: String }, /// A bare boolean variable must be true (e.g., `payment_captured`). /// /// `expect = true` matches `name`; `expect = false` matches `!name`. @@ -48,6 +60,58 @@ pub enum ParsedAssert { Or(Vec), } +impl ParsedAssert { + /// Return whether every typed reference is declared and the assertion + /// shape is supported by the verification contract. + /// + /// Runtime-enforced leaves are supported on their own. Mixing them into a + /// boolean compound is not yet atomic across all verification backends and + /// therefore remains unsupported. Ordering assertions are likewise not in + /// the verified subset. + pub fn is_supported_safety_assertion( + &self, + bool_names: &BTreeSet, + counter_names: &BTreeSet, + string_names: &BTreeSet, + status_names: &BTreeSet, + ) -> bool { + match self { + Self::Always | Self::NoFurtherTransitions => true, + Self::CounterPositive { var } | Self::CounterCompare { var, .. } => { + counter_names.contains(var) + } + Self::CounterVarCompare { left, right, .. } => { + counter_names.contains(left) && counter_names.contains(right) + } + Self::StringNonEmpty { var } => string_names.contains(var), + Self::BoolRequired { var, .. } => bool_names.contains(var), + Self::NeverState { state } => status_names.contains(state), + Self::OrderingConstraint { .. } => false, + Self::And(parts) | Self::Or(parts) => { + !parts.iter().any(Self::contains_runtime_enforced_leaf) + && parts.iter().all(|part| { + part.is_supported_safety_assertion( + bool_names, + counter_names, + string_names, + status_names, + ) + }) + } + } + } + + fn contains_runtime_enforced_leaf(&self) -> bool { + match self { + Self::CounterVarCompare { .. } | Self::StringNonEmpty { .. } => true, + Self::And(parts) | Self::Or(parts) => { + parts.iter().any(Self::contains_runtime_enforced_leaf) + } + _ => false, + } + } +} + /// Parse an assertion expression from an IOA spec into a [`ParsedAssert`]. /// /// Returns `None` for expressions that cannot be structurally parsed. @@ -60,7 +124,7 @@ pub enum ParsedAssert { /// and_expr = atom ( "&&" atom )* /// atom = "(" expr ")" | "!" atom | terminal /// terminal = counter_cmp | ordering(...) | never(...) -/// | no_further_transitions | bare_bool +/// | true | no_further_transitions | is_true bool | bare_bool /// ``` /// /// # Recognized terminals @@ -71,6 +135,8 @@ pub enum ParsedAssert { /// - `"never(StateName)"` → `NeverState` /// - `"var >= N"`, `"var <= N"`, `"var == N"`, `"var > N"`, `"var < N"` → `CounterCompare` /// - `"flag"` / `"!flag"` → `BoolRequired` +/// - `"is_true flag"` → `BoolRequired` +/// - `"true"` → `Always` /// /// # Compound expressions /// @@ -78,6 +144,13 @@ pub enum ParsedAssert { /// - `"a || b"` → `Or(vec![a, b])` /// - `"a && (b || c)"` → `And(vec![a, Or(vec![b, c])])` pub fn parse_assert_expr(expr: &str) -> Option { + if let Some(var) = expr.trim().strip_suffix("!= ''").map(str::trim) + && is_identifier(var) + { + return Some(ParsedAssert::StringNonEmpty { + var: var.to_string(), + }); + } let tokens = tokenize(expr)?; let mut cursor = 0; let result = parse_or(&tokens, &mut cursor)?; @@ -251,6 +324,19 @@ fn parse_terminal(tokens: &[Tok], cursor: &mut usize) -> Option { // no_further_transitions (bare identifier). if let Tok::Ident(name) = &first { + if name == "true" { + *cursor += 1; + return Some(ParsedAssert::Always); + } + + if name == "is_true" { + let Tok::Ident(var) = tokens.get(*cursor + 1)?.clone() else { + return None; + }; + *cursor += 2; + return Some(ParsedAssert::BoolRequired { var, expect: true }); + } + // ordering(A, B) if name == "ordering" && matches!(tokens.get(*cursor + 1), Some(Tok::LParen)) { *cursor += 2; @@ -290,7 +376,7 @@ fn parse_terminal(tokens: &[Tok], cursor: &mut usize) -> Option { return Some(ParsedAssert::NeverState { state }); } - // Counter comparison: "var OP N" + // Counter comparison: "var OP N". if let (Some(Tok::CmpOp(op)), Some(Tok::Number(n))) = (tokens.get(*cursor + 1), tokens.get(*cursor + 2)) { @@ -304,6 +390,16 @@ fn parse_terminal(tokens: &[Tok], cursor: &mut usize) -> Option { } return Some(ParsedAssert::CounterCompare { var, op, value }); } + if let (Some(Tok::CmpOp(op)), Some(Tok::Ident(right))) = + (tokens.get(*cursor + 1), tokens.get(*cursor + 2)) + { + *cursor += 3; + return Some(ParsedAssert::CounterVarCompare { + left: name.clone(), + op: op.clone(), + right: right.clone(), + }); + } // no_further_transitions (exactly). if name == "no_further_transitions" { @@ -324,10 +420,48 @@ fn parse_terminal(tokens: &[Tok], cursor: &mut usize) -> Option { None } +fn is_identifier(value: &str) -> bool { + let mut characters = value.chars(); + characters + .next() + .is_some_and(|character| character.is_ascii_alphabetic() || character == '_') + && characters.all(|character| character.is_ascii_alphanumeric() || character == '_') +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn parses_production_boolean_aliases() { + assert_eq!(parse_assert_expr("true"), Some(ParsedAssert::Always)); + assert_eq!( + parse_assert_expr("is_true ready"), + Some(ParsedAssert::BoolRequired { + var: "ready".to_string(), + expect: true, + }) + ); + } + + #[test] + fn parses_runtime_enforced_production_forms() { + assert_eq!( + parse_assert_expr("used_bytes <= quota_limit"), + Some(ParsedAssert::CounterVarCompare { + left: "used_bytes".to_string(), + op: AssertCompareOp::Lte, + right: "quota_limit".to_string(), + }) + ); + assert_eq!( + parse_assert_expr("goal != ''"), + Some(ParsedAssert::StringNonEmpty { + var: "goal".to_string(), + }) + ); + } + #[test] fn test_counter_positive() { assert_eq!( @@ -663,4 +797,36 @@ mod tests { assert_eq!(parse_assert_expr("a &&"), None); assert_eq!(parse_assert_expr("&& a"), None); } + + #[test] + fn safety_support_contract_validates_declarations_and_shape() { + let bools = BTreeSet::from(["ready".to_string()]); + let counters = BTreeSet::from(["items".to_string()]); + let strings = BTreeSet::from(["goal".to_string()]); + let statuses = BTreeSet::from(["Draft".to_string()]); + + assert!( + parse_assert_expr("items > 0") + .unwrap() + .is_supported_safety_assertion(&bools, &counters, &strings, &statuses) + ); + for unsupported in ["ghost > 0", "never(Ghost)", "ordering(Draft, Ghost)"] { + assert!( + !parse_assert_expr(unsupported) + .unwrap() + .is_supported_safety_assertion(&bools, &counters, &strings, &statuses), + "{unsupported} must remain outside the supported safety contract" + ); + } + let compound_runtime = ParsedAssert::And(vec![ + ParsedAssert::StringNonEmpty { var: "goal".into() }, + ParsedAssert::BoolRequired { + var: "ready".into(), + expect: true, + }, + ]); + assert!( + !compound_runtime.is_supported_safety_assertion(&bools, &counters, &strings, &statuses) + ); + } } diff --git a/crates/temper-spec/src/automaton/mod.rs b/crates/temper-spec/src/automaton/mod.rs index e04f6aa9b..bb0875156 100644 --- a/crates/temper-spec/src/automaton/mod.rs +++ b/crates/temper-spec/src/automaton/mod.rs @@ -18,6 +18,7 @@ mod initial; mod lint; pub mod metadata; pub mod parser; +mod runtime_assert; mod toml_parser; pub mod translate; pub mod trigger_graph; @@ -36,6 +37,11 @@ pub use parser::{ LivenessEnforcement, LivenessViolationReporter, parse_automaton, parse_automaton_with_liveness, set_liveness_violation_reporter, to_state_machine, }; +pub use runtime_assert::{ + RUNTIME_INVARIANT_ENFORCEMENT_VERSION, RuntimeAssert, RuntimeInvariant, + compile_runtime_invariants, evaluate_runtime_assert, model_protected_state_var_names, + unsupported_safety_invariant_names, +}; pub use translate::{ResolvedAction, ResolvedEffect, ResolvedGuard, translate_actions}; pub use trigger_graph::{TriggerEdge, TriggerGraph}; pub use types::*; diff --git a/crates/temper-spec/src/automaton/runtime_assert.rs b/crates/temper-spec/src/automaton/runtime_assert.rs new file mode 100644 index 000000000..9ba07014c --- /dev/null +++ b/crates/temper-spec/src/automaton/runtime_assert.rs @@ -0,0 +1,374 @@ +//! Runtime-enforced safety assertions shared by production and deterministic simulation. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::{AssertCompareOp, Automaton, ParsedAssert, parse_assert_expr}; + +struct SafetyDeclarations { + bool_names: BTreeSet, + counter_names: BTreeSet, + string_names: BTreeSet, + status_names: BTreeSet, +} + +impl SafetyDeclarations { + fn from_automaton(automaton: &Automaton) -> Self { + Self { + bool_names: declared_names(automaton, "bool"), + counter_names: declared_names(automaton, "counter"), + string_names: declared_names(automaton, "string"), + status_names: automaton.automaton.states.iter().cloned().collect(), + } + } + + fn supports(&self, when: &[String], assertion: &str) -> bool { + when.iter().all(|state| self.status_names.contains(state)) + && parse_assert_expr(assertion).is_some_and(|parsed| { + parsed.is_supported_safety_assertion( + &self.bool_names, + &self.counter_names, + &self.string_names, + &self.status_names, + ) + }) + } +} + +/// Version of the runtime-invariant enforcement contract. +pub const RUNTIME_INVARIANT_ENFORCEMENT_VERSION: u32 = 2; + +/// Assertion forms whose safety is enforced on tentative runtime state. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum RuntimeAssert { + /// An assertion that always holds. + Always, + /// A declared counter must remain positive. + CounterPositive { var: String }, + /// Compare a declared counter with a literal bound. + CounterCompare { + var: String, + op: AssertCompareOp, + value: usize, + }, + /// A declared string field must contain at least one character. + StringNonEmpty { var: String }, + /// Compare one declared counter with another. + CounterVarCompare { + left: String, + op: AssertCompareOp, + right: String, + }, + /// A declared boolean must equal the expected value. + BoolRequired { var: String, expect: bool }, + /// The entity must not enter the named state. + NeverState { state: String }, + /// Every nested assertion must hold. + And(Vec), + /// At least one nested assertion must hold. + Or(Vec), +} + +/// A named runtime-enforced assertion and its activating states. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RuntimeInvariant { + /// Declared invariant name. + pub name: String, + /// States in which the assertion applies; empty means every state. + pub when: Vec, + /// Typed runtime assertion. + pub assertion: RuntimeAssert, + /// Stable enforcement contract version attached at compilation. + pub enforcement_version: u32, +} + +/// Compile the runtime-enforced subset of an automaton's safety assertions. +pub fn compile_runtime_invariants(automaton: &Automaton) -> Vec { + let declarations = SafetyDeclarations::from_automaton(automaton); + + automaton + .invariants + .iter() + .filter_map(|invariant| { + let parsed = parse_assert_expr(&invariant.assert)?; + if !declarations.supports(&invariant.when, &invariant.assert) { + return None; + } + let inherently_runtime_enforced = matches!( + parsed, + ParsedAssert::StringNonEmpty { .. } | ParsedAssert::CounterVarCompare { .. } + ); + if !inherently_runtime_enforced + && !requires_parameter_runtime_enforcement(&parsed, &invariant.when, automaton) + { + return None; + } + let assertion = compile_runtime_assert(parsed)?; + Some(RuntimeInvariant { + name: invariant.name.clone(), + when: invariant.when.clone(), + assertion, + enforcement_version: RUNTIME_INVARIANT_ENFORCEMENT_VERSION, + }) + }) + .collect() +} + +fn compile_runtime_assert(parsed: ParsedAssert) -> Option { + Some(match parsed { + ParsedAssert::Always => RuntimeAssert::Always, + ParsedAssert::CounterPositive { var } => RuntimeAssert::CounterPositive { var }, + ParsedAssert::CounterCompare { var, op, value } => { + RuntimeAssert::CounterCompare { var, op, value } + } + ParsedAssert::StringNonEmpty { var } => RuntimeAssert::StringNonEmpty { var }, + ParsedAssert::CounterVarCompare { left, op, right } => { + RuntimeAssert::CounterVarCompare { left, op, right } + } + ParsedAssert::BoolRequired { var, expect } => RuntimeAssert::BoolRequired { var, expect }, + ParsedAssert::NeverState { state } => RuntimeAssert::NeverState { state }, + ParsedAssert::NoFurtherTransitions => return None, + ParsedAssert::And(parts) => RuntimeAssert::And( + parts + .into_iter() + .map(compile_runtime_assert) + .collect::>>()?, + ), + ParsedAssert::Or(parts) => RuntimeAssert::Or( + parts + .into_iter() + .map(compile_runtime_assert) + .collect::>>()?, + ), + ParsedAssert::OrderingConstraint { .. } => return None, + }) +} + +/// Return every declared safety invariant that cannot be enforced by the +/// shared verification/runtime capability contract. +/// +/// Registry activation uses this dependency-light preflight so unsupported +/// specs cannot become live even when higher verification layers are bypassed. +pub fn unsupported_safety_invariant_names(automaton: &Automaton) -> Vec { + let declarations = SafetyDeclarations::from_automaton(automaton); + automaton + .invariants + .iter() + .filter(|invariant| { + !declarations.supports(&invariant.when, &invariant.assert) + || parse_assert_expr(&invariant.assert).is_some_and(|parsed| { + requires_parameter_runtime_enforcement(&parsed, &invariant.when, automaton) + && depends_on_no_further_transitions(&parsed) + }) + }) + .map(|invariant| invariant.name.clone()) + .collect() +} + +/// Return declared counter and boolean variables whose values are governed by +/// model-proved invariants, including assertions that are also enforced at +/// runtime because transition parameters are outside the finite model. +/// +/// Caller payloads must not mutate these variables directly. Their values are +/// changed only by transition effects, while runtime enforcement rejects any +/// parameter-derived result outside the state space proved by verification. +/// Protection covers the complete logical model state whenever any invariant +/// depends on model reachability: guard-only variables can otherwise make a +/// transition reachable in production that was unreachable during proof. +pub fn model_protected_state_var_names(automaton: &Automaton) -> BTreeSet { + let declarations = SafetyDeclarations::from_automaton(automaton); + let has_model_proved_invariant = automaton.invariants.iter().any(|invariant| { + declarations.supports(&invariant.when, &invariant.assert) + && parse_assert_expr(&invariant.assert).is_some_and(|assertion| { + !matches!( + assertion, + ParsedAssert::StringNonEmpty { .. } | ParsedAssert::CounterVarCompare { .. } + ) + }) + }); + if has_model_proved_invariant { + declarations + .bool_names + .union(&declarations.counter_names) + .cloned() + .collect() + } else { + BTreeSet::new() + } +} + +fn declared_names(automaton: &Automaton, var_type: &str) -> BTreeSet { + automaton + .state + .iter() + .filter(|state| state.var_type == var_type) + .map(|state| state.name.clone()) + .collect() +} + +/// Evaluate a typed runtime assertion against tentative entity state. +pub fn evaluate_runtime_assert( + assertion: &RuntimeAssert, + status: &str, + counters: &BTreeMap, + booleans: &BTreeMap, + fields: &serde_json::Map, +) -> bool { + match assertion { + RuntimeAssert::Always => true, + RuntimeAssert::CounterPositive { var } => counters.get(var).copied().unwrap_or(0) > 0, + RuntimeAssert::CounterCompare { var, op, value } => { + compare_counter(counters.get(var).copied().unwrap_or(0), op, *value) + } + RuntimeAssert::StringNonEmpty { var } => fields.get(var).is_some_and(|value| { + value.as_str().is_some_and(|value| !value.is_empty()) + || value.as_object().is_some_and(|object| { + object + .get("__temper_blob_ref") + .and_then(serde_json::Value::as_str) + .is_some_and(|reference| !reference.is_empty()) + && object + .get("__temper_blob_encoding") + .and_then(serde_json::Value::as_str) + == Some("json") + }) + }), + RuntimeAssert::CounterVarCompare { left, op, right } => { + let left = counters.get(left).copied().unwrap_or(0); + let right = counters.get(right).copied().unwrap_or(0); + compare_counter(left, op, right) + } + RuntimeAssert::BoolRequired { var, expect } => { + booleans.get(var).copied().unwrap_or(false) == *expect + } + RuntimeAssert::NeverState { state } => status != state, + RuntimeAssert::And(parts) => parts + .iter() + .all(|part| evaluate_runtime_assert(part, status, counters, booleans, fields)), + RuntimeAssert::Or(parts) => parts + .iter() + .any(|part| evaluate_runtime_assert(part, status, counters, booleans, fields)), + } +} + +fn requires_parameter_runtime_enforcement( + assertion: &ParsedAssert, + active_states: &[String], + automaton: &Automaton, +) -> bool { + if assertion_is_tautology(assertion) { + return false; + } + let parameter_counters: BTreeSet<&str> = automaton + .actions + .iter() + .filter(|action| action.kind != "output") + .flat_map(|action| &action.effect) + .filter_map(|effect| match effect { + super::Effect::Increment { + var, + amount: Some(_), + } + | super::Effect::Decrement { + var, + amount: Some(_), + } + | super::Effect::SetCounterFromParam { var, .. } => Some(var.as_str()), + _ => None, + }) + .collect(); + if parameter_counters.is_empty() { + return false; + } + + assertion_references_any_counter(assertion, ¶meter_counters) + || automaton + .actions + .iter() + .filter(|action| action.kind != "output") + .filter(|action| { + if !is_pure_terminal_assertion(assertion) { + return true; + } + active_states.is_empty() + || action.from.is_empty() + || action + .from + .iter() + .any(|state| active_states.contains(state)) + }) + .flat_map(|action| &action.guard) + .any(|guard| match guard { + super::Guard::MinCount { var, .. } | super::Guard::MaxCount { var, .. } => { + parameter_counters.contains(var.as_str()) + } + _ => false, + }) +} + +fn assertion_references_any_counter(assertion: &ParsedAssert, counters: &BTreeSet<&str>) -> bool { + match assertion { + ParsedAssert::CounterPositive { var } | ParsedAssert::CounterCompare { var, .. } => { + counters.contains(var.as_str()) + } + ParsedAssert::CounterVarCompare { left, right, .. } => { + counters.contains(left.as_str()) || counters.contains(right.as_str()) + } + ParsedAssert::And(parts) | ParsedAssert::Or(parts) => parts + .iter() + .any(|part| assertion_references_any_counter(part, counters)), + _ => false, + } +} + +fn depends_on_no_further_transitions(assertion: &ParsedAssert) -> bool { + if assertion_is_tautology(assertion) { + return false; + } + match assertion { + ParsedAssert::NoFurtherTransitions => true, + ParsedAssert::And(parts) | ParsedAssert::Or(parts) => { + parts.iter().any(depends_on_no_further_transitions) + } + _ => false, + } +} + +fn is_pure_terminal_assertion(assertion: &ParsedAssert) -> bool { + depends_on_no_further_transitions(assertion) && !contains_nonterminal_safety_leaf(assertion) +} + +fn assertion_is_tautology(assertion: &ParsedAssert) -> bool { + match assertion { + ParsedAssert::Always => true, + ParsedAssert::And(parts) => parts.iter().all(assertion_is_tautology), + ParsedAssert::Or(parts) => parts.iter().any(assertion_is_tautology), + _ => false, + } +} + +fn contains_nonterminal_safety_leaf(assertion: &ParsedAssert) -> bool { + if assertion_is_tautology(assertion) { + return false; + } + match assertion { + ParsedAssert::Always | ParsedAssert::NoFurtherTransitions => false, + ParsedAssert::And(parts) | ParsedAssert::Or(parts) => { + parts.iter().any(contains_nonterminal_safety_leaf) + } + _ => true, + } +} + +fn compare_counter(left: usize, op: &AssertCompareOp, right: usize) -> bool { + match op { + AssertCompareOp::Gt => left > right, + AssertCompareOp::Gte => left >= right, + AssertCompareOp::Lt => left < right, + AssertCompareOp::Lte => left <= right, + AssertCompareOp::Eq => left == right, + } +} + +#[cfg(test)] +#[path = "runtime_assert_test.rs"] +mod tests; diff --git a/crates/temper-spec/src/automaton/runtime_assert_test.rs b/crates/temper-spec/src/automaton/runtime_assert_test.rs new file mode 100644 index 000000000..183109e1a --- /dev/null +++ b/crates/temper-spec/src/automaton/runtime_assert_test.rs @@ -0,0 +1,295 @@ +use super::*; +use crate::automaton::parse_automaton; +use std::collections::BTreeMap; + +fn parameter_counter_spec(assertion: &str) -> Automaton { + parse_automaton(&format!( + r#" +[automaton] +name = "Budget" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "budget" +type = "counter" +initial = "1" + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["budget"] +effect = [{{ type = "set_counter_from_param", var = "budget", param = "budget" }}] + +[[invariant]] +name = "BudgetInvariant" +when = ["Active"] +assert = "{assertion}" +"# + )) + .expect("parameter counter spec must parse") +} + +fn evaluate_budget(assertion: &RuntimeAssert, budget: usize) -> bool { + evaluate_runtime_assert( + assertion, + "Active", + &BTreeMap::from([("budget".to_string(), budget)]), + &BTreeMap::new(), + &serde_json::Map::new(), + ) +} + +fn guarded_terminal_spec(guard_from: &str) -> Automaton { + parse_automaton(&format!( + r#" +[automaton] +name = "GuardedBudget" +states = ["Active", "Done"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "budget" +type = "counter" +initial = "1" + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["budget"] +effect = [{{ type = "set_counter_from_param", var = "budget", param = "budget" }}] + +[[action]] +name = "Finish" +kind = "input" +from = ["Active"] +to = "Done" + +[[action]] +name = "Guarded" +kind = "input" +from = ["{guard_from}"] +to = "{guard_from}" +guard = [{{ type = "min_count", var = "budget", min = 2 }}] + +[[invariant]] +name = "DoneIsFinal" +when = ["Done"] +assert = "no_further_transitions" +"# + )) + .expect("guarded terminal spec must parse") +} + +#[test] +fn parameter_counter_compounds_preserve_and_or_semantics() { + let and_spec = parameter_counter_spec("budget <= 10 && budget > 0"); + let and_invariants = compile_runtime_invariants(&and_spec); + assert_eq!(and_invariants.len(), 1); + assert!(matches!(and_invariants[0].assertion, RuntimeAssert::And(_))); + assert!(evaluate_budget(&and_invariants[0].assertion, 5)); + assert!(!evaluate_budget(&and_invariants[0].assertion, 0)); + assert!(!evaluate_budget(&and_invariants[0].assertion, 100)); + + let or_spec = parameter_counter_spec("budget <= 10 || budget == 11"); + let or_invariants = compile_runtime_invariants(&or_spec); + assert_eq!(or_invariants.len(), 1); + assert!(matches!(or_invariants[0].assertion, RuntimeAssert::Or(_))); + assert!(evaluate_budget(&or_invariants[0].assertion, 11)); + assert!(!evaluate_budget(&or_invariants[0].assertion, 100)); +} + +#[test] +fn parameter_counter_effect_rejects_terminal_assertion_without_runtime_equivalence() { + let spec = parameter_counter_spec("budget <= 10 || no_further_transitions"); + assert_eq!( + unsupported_safety_invariant_names(&spec), + vec!["BudgetInvariant"] + ); + assert!(compile_runtime_invariants(&spec).is_empty()); +} + +#[test] +fn parameter_counter_effect_preserves_unrelated_terminal_assertion() { + let spec = parameter_counter_spec("no_further_transitions"); + assert!(unsupported_safety_invariant_names(&spec).is_empty()); + assert!(compile_runtime_invariants(&spec).is_empty()); +} + +#[test] +fn parameter_counter_guard_rejects_terminal_assertion_in_same_state() { + let spec = guarded_terminal_spec("Done"); + assert_eq!( + unsupported_safety_invariant_names(&spec), + vec!["DoneIsFinal"] + ); +} + +#[test] +fn parameter_counter_guard_preserves_terminal_assertion_in_unrelated_state() { + let spec = guarded_terminal_spec("Active"); + assert!(unsupported_safety_invariant_names(&spec).is_empty()); +} + +#[test] +fn output_parameter_dependencies_do_not_affect_runtime_admission() { + let mut spec = guarded_terminal_spec("Done"); + let guarded = spec + .actions + .iter_mut() + .find(|action| action.name == "Guarded") + .expect("guarded action exists"); + guarded.kind = "output".to_string(); + assert!(unsupported_safety_invariant_names(&spec).is_empty()); +} + +#[test] +fn tautological_or_does_not_depend_on_terminal_semantics() { + let mut spec = guarded_terminal_spec("Done"); + spec.invariants[0].assert = "true || no_further_transitions".to_string(); + assert!(unsupported_safety_invariant_names(&spec).is_empty()); + + spec.invariants[0].assert = "true && no_further_transitions".to_string(); + assert_eq!( + unsupported_safety_invariant_names(&spec), + vec!["DoneIsFinal"] + ); +} + +#[test] +fn tautologies_do_not_compile_runtime_contracts() { + let plain = parameter_counter_spec("true"); + assert!(compile_runtime_invariants(&plain).is_empty()); + + let compound = parameter_counter_spec("true || budget <= 10"); + assert!(compile_runtime_invariants(&compound).is_empty()); +} + +#[test] +fn parameter_counter_guard_enforces_multihop_downstream_invariant() { + let spec = parse_automaton( + r#" +[automaton] +name = "MultihopBudget" +states = ["Active", "Unlocked", "Done"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "budget" +type = "counter" +initial = "1" + +[[state]] +name = "safe" +type = "bool" +initial = "false" + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["budget"] +effect = [{ type = "set_counter_from_param", var = "budget", param = "budget" }] + +[[action]] +name = "Unlock" +kind = "input" +from = ["Active"] +to = "Unlocked" +guard = [{ type = "min_count", var = "budget", min = 2 }] + +[[action]] +name = "Finish" +kind = "input" +from = ["Unlocked"] +to = "Done" + +[[invariant]] +name = "DoneMustBeSafe" +when = ["Done"] +assert = "safe" +"#, + ) + .expect("multi-hop guard spec must parse"); + + assert!(unsupported_safety_invariant_names(&spec).is_empty()); + let runtime = compile_runtime_invariants(&spec); + assert_eq!(runtime.len(), 1); + assert!(matches!( + runtime[0].assertion, + RuntimeAssert::BoolRequired { + ref var, + expect: true + } if var == "safe" + )); +} + +#[test] +fn parameter_counter_guard_rejects_multihop_mixed_terminal_compound() { + let spec = parse_automaton( + r#" +[automaton] +name = "MixedTerminal" +states = ["Active", "Unlocked", "Done"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "budget" +type = "counter" +initial = "1" + +[[state]] +name = "safe" +type = "bool" +initial = "false" + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["budget"] +effect = [{ type = "set_counter_from_param", var = "budget", param = "budget" }] + +[[action]] +name = "Unlock" +kind = "input" +from = ["Active"] +to = "Unlocked" +guard = [{ type = "min_count", var = "budget", min = 2 }] + +[[action]] +name = "Finish" +kind = "input" +from = ["Unlocked"] +to = "Done" + +[[action]] +name = "Continue" +kind = "input" +from = ["Done"] +to = "Done" + +[[invariant]] +name = "DoneSafety" +when = ["Done"] +assert = "safe && no_further_transitions" +"#, + ) + .expect("mixed terminal compound spec must parse"); + + assert_eq!( + unsupported_safety_invariant_names(&spec), + vec!["DoneSafety"] + ); + assert!(compile_runtime_invariants(&spec).is_empty()); +} diff --git a/crates/temper-spec/src/automaton/toml_parser/effects.rs b/crates/temper-spec/src/automaton/toml_parser/effects.rs index 442d305dc..8efec6882 100644 --- a/crates/temper-spec/src/automaton/toml_parser/effects.rs +++ b/crates/temper-spec/src/automaton/toml_parser/effects.rs @@ -1,5 +1,5 @@ use super::AutomatonParseError; -use super::inline::{parse_inline_fields, split_inline_tables}; +use super::inline::{parse_inline_fields, parse_scalar_value, split_top_level}; use crate::automaton::Effect; pub(super) fn parse_effect_value( @@ -26,12 +26,18 @@ fn parse_effect_array(value: &str, effects: &mut Vec) -> Result<(), Auto } let inner = &trimmed[1..trimmed.len() - 1]; - for entry in split_inline_tables(inner) { - let entry = entry.trim().trim_matches('{').trim_matches('}').trim(); - let fields = parse_inline_fields(entry); - - if let Some(effect) = parse_effect_fields(&fields)? { - effects.push(effect); + for entry in split_top_level(inner, ',') { + let entry = entry.trim(); + if entry.starts_with('{') { + let fields = parse_inline_fields(entry.trim_matches('{').trim_matches('}').trim()); + if let Some(effect) = parse_effect_fields(&fields)? { + effects.push(effect); + } + } else { + let legacy = parse_scalar_value(entry); + if let Some(effect) = parse_legacy_effect(&legacy) { + effects.push(effect); + } } } diff --git a/crates/temper-spec/src/automaton/toml_parser/inline.rs b/crates/temper-spec/src/automaton/toml_parser/inline.rs index 6d285f007..ecc8410ee 100644 --- a/crates/temper-spec/src/automaton/toml_parser/inline.rs +++ b/crates/temper-spec/src/automaton/toml_parser/inline.rs @@ -2,10 +2,34 @@ pub(super) fn parse_kv(line: &str) -> Option<(&str, String)> { let eq = line.find('=')?; let key = line[..eq].trim(); let raw_value = line[eq + 1..].trim(); - let value = raw_value.trim_matches('"').trim_matches('\'').to_string(); + let value = parse_scalar_value(raw_value); Some((key, value)) } +pub(super) fn parse_scalar_value(raw_value: &str) -> String { + let Some(delimiter) = raw_value + .chars() + .next() + .filter(|ch| matches!(ch, '"' | '\'')) + else { + return raw_value.to_string(); + }; + + let content = &raw_value[delimiter.len_utf8()..]; + let mut escaped = false; + for (offset, character) in content.char_indices() { + if character == delimiter && !escaped { + return content[..offset].to_string(); + } + escaped = delimiter == '"' && character == '\\' && !escaped; + if character != '\\' { + escaped = false; + } + } + + raw_value.to_string() +} + pub(super) fn parse_string_array(value: &str) -> Vec { let trimmed = value.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { @@ -149,7 +173,7 @@ pub(super) fn join_multiline_arrays(input: &str) -> Vec { result } -fn split_top_level(s: &str, delimiter: char) -> Vec<&str> { +pub(super) fn split_top_level(s: &str, delimiter: char) -> Vec<&str> { let mut result = Vec::new(); let mut start = 0; let mut bracket_depth = 0_i32; diff --git a/crates/temper-spec/src/automaton/toml_parser/mod.rs b/crates/temper-spec/src/automaton/toml_parser/mod.rs index d1511ed96..eaa68f89e 100644 --- a/crates/temper-spec/src/automaton/toml_parser/mod.rs +++ b/crates/temper-spec/src/automaton/toml_parser/mod.rs @@ -364,6 +364,7 @@ impl ParseState { name: String::new(), when: Vec::new(), assert: String::new(), + assert_span: None, }); self.current_section = Section::Invariant; true @@ -438,6 +439,7 @@ pub(super) fn parse_toml_to_automaton(input: &str) -> Result Result Option> { + let content_start = line.len() - line.trim_start().len(); + let trimmed = &line[content_start..]; + let (key, remainder) = trimmed.split_once('=')?; + if key.trim() != "assert" { + return None; + } + + let value_leading = remainder.len() - remainder.trim_start().len(); + let value = remainder.trim_start(); + let value_start = content_start + key.len() + 1 + value_leading; + match value.chars().next() { + Some(delimiter @ ('"' | '\'')) => { + let quoted = &value[delimiter.len_utf8()..]; + let quote_end = closing_quote_offset(quoted, delimiter)?; + Some((value_start + 1)..(value_start + 1 + quote_end)) + } + _ => { + let value_end = value.trim_end().len(); + Some(value_start..(value_start + value_end)) + } + } +} + +fn closing_quote_offset(value: &str, delimiter: char) -> Option { + let mut escaped = false; + for (offset, character) in value.char_indices() { + if character == delimiter && !escaped { + return Some(offset); + } + escaped = delimiter == '"' && character == '\\' && !escaped; + if character != '\\' { + escaped = false; + } + } + None +} + +fn source_position(source: &str, byte: usize) -> SourcePosition { + debug_assert!(source.is_char_boundary(byte)); + let prefix = &source[..byte]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = prefix + .rsplit_once('\n') + .map_or(prefix, |(_, current_line)| current_line) + .chars() + .count() + + 1; + SourcePosition { byte, line, column } +} + /// Extract `[[webhook]]` sections from TOML source via serde. /// /// The hand-written parser does not handle `[[webhook]]` sections, so @@ -790,3 +886,7 @@ fn isolate_field_invariant_sections(source: &str) -> String { #[cfg(test)] #[path = "tests.rs"] mod tests; + +#[cfg(test)] +#[path = "source_span_tests.rs"] +mod source_span_tests; diff --git a/crates/temper-spec/src/automaton/toml_parser/source_span_tests.rs b/crates/temper-spec/src/automaton/toml_parser/source_span_tests.rs new file mode 100644 index 000000000..f77911539 --- /dev/null +++ b/crates/temper-spec/src/automaton/toml_parser/source_span_tests.rs @@ -0,0 +1,89 @@ +use super::*; + +const PREFIX: &str = + "[automaton]\nname = \"SpanTest\"\nstates = [\"Ready\"]\ninitial = \"Ready\"\n"; + +#[test] +fn invariant_assertion_retains_exact_source_span() { + let source = format!( + "{PREFIX}\n[[invariant]]\nname = \"Unsupported\"\nwhen = [\"Ready\"]\n assert = \"used_bytes <= quota_limit\" # safety claim\n" + ); + let automaton = parse_toml_to_automaton(&source).expect("parse IOA"); + let span = automaton.invariants[0] + .assert_span + .expect("assertion source span"); + assert_eq!( + &source[span.start.byte..span.end.byte], + "used_bytes <= quota_limit" + ); + assert_eq!((span.start.line, span.start.column), (9, 13)); + assert_eq!((span.end.line, span.end.column), (9, 38)); +} + +#[test] +fn single_quoted_invariant_retains_inner_source_span() { + let source = "[automaton]\nname = 'Entity'\nstates = ['Active']\ninitial = 'Active'\n\n[[invariant]]\nname = 'Flag'\nassert = 'declared_flag'\n"; + let automaton = parse_toml_to_automaton(source).expect("parse IOA"); + let span = automaton.invariants[0] + .assert_span + .expect("assertion source span"); + assert_eq!(&source[span.start.byte..span.end.byte], "declared_flag"); +} + +#[test] +fn crlf_invariant_retains_inner_source_span() { + let source = "[automaton]\r\nname = \"SpanTest\"\r\nstates = [\"Ready\"]\r\ninitial = \"Ready\"\r\n\r\n[[invariant]]\r\nname = \"Unsupported\"\r\nwhen = [\"Ready\"]\r\nassert = \"used_bytes <= quota_limit\"\r\n"; + let automaton = parse_toml_to_automaton(source).expect("parse IOA"); + let span = automaton.invariants[0] + .assert_span + .expect("assertion source span"); + assert_eq!( + &source[span.start.byte..span.end.byte], + "used_bytes <= quota_limit" + ); + assert_eq!((span.start.line, span.start.column), (9, 11)); +} + +#[test] +fn repeated_invariants_receive_distinct_source_spans() { + let source = format!( + "{PREFIX}\n[[invariant]]\nname = \"First\"\nassert = \"value != ''\"\n\n[[invariant]]\nname = \"Second\"\nassert = \"value != ''\"\n" + ); + let automaton = parse_toml_to_automaton(&source).expect("parse IOA"); + let first = automaton.invariants[0].assert_span.expect("first span"); + let second = automaton.invariants[1].assert_span.expect("second span"); + assert_ne!(first, second); + assert_eq!(&source[first.start.byte..first.end.byte], "value != ''"); + assert_eq!(&source[second.start.byte..second.end.byte], "value != ''"); +} + +#[test] +fn parse_kv_preserves_quotes_inside_assertion() { + let (key, value) = parse_kv("assert = \"goal != ''\"").expect("key/value"); + assert_eq!(key, "assert"); + assert_eq!(value, "goal != ''"); +} + +#[test] +fn parse_kv_ignores_inline_comment_after_quoted_value() { + let (_, value) = + parse_kv("assert = \"used_bytes <= quota_limit\" # safety claim").expect("key/value"); + assert_eq!(value, "used_bytes <= quota_limit"); +} + +#[test] +fn mixed_legacy_and_inline_effect_array_retains_both_effects() { + let source = format!( + "{PREFIX}\n[[state]]\nname = \"ready\"\ntype = \"bool\"\ninitial = \"false\"\n\n[[action]]\nname = \"Activate\"\nfrom = [\"Ready\"]\neffect = [\"set ready true\", {{ type = \"emit\", event = \"Activated\" }}]\n" + ); + let automaton = parse_toml_to_automaton(&source).expect("parse IOA"); + assert_eq!(automaton.actions[0].effect.len(), 2); + assert!(matches!( + &automaton.actions[0].effect[0], + Effect::SetBool { var, value: true } if var == "ready" + )); + assert!(matches!( + &automaton.actions[0].effect[1], + Effect::Emit { event } if event == "Activated" + )); +} diff --git a/crates/temper-spec/src/automaton/types.rs b/crates/temper-spec/src/automaton/types.rs index 34cf56049..cbf7df30c 100644 --- a/crates/temper-spec/src/automaton/types.rs +++ b/crates/temper-spec/src/automaton/types.rs @@ -394,6 +394,33 @@ pub struct Invariant { pub when: Vec, /// The assertion (a simple expression). pub assert: String, + /// Exact source range of the assertion expression, excluding TOML quotes. + /// + /// This parser metadata is not part of the serialized specification. It is + /// retained so verification diagnostics can identify an unsupported safety + /// claim without reparsing or guessing its location. + #[serde(skip)] + pub assert_span: Option, +} + +/// A one-based source position with its zero-based UTF-8 byte offset. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourcePosition { + /// Zero-based UTF-8 byte offset from the start of the specification. + pub byte: usize, + /// One-based line number. + pub line: usize, + /// One-based Unicode scalar column. + pub column: usize, +} + +/// A half-open source range: `start` is inclusive and `end` is exclusive. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceSpan { + /// Inclusive start position. + pub start: SourcePosition, + /// Exclusive end position. + pub end: SourcePosition, } /// A liveness property. diff --git a/crates/temper-verify/src/cascade.rs b/crates/temper-verify/src/cascade.rs index b57b7bd3f..31c417803 100644 --- a/crates/temper-verify/src/cascade.rs +++ b/crates/temper-verify/src/cascade.rs @@ -10,7 +10,11 @@ //! Each level produces a pass/fail result. All levels run independently. use crate::checker::{self, VerificationResult}; -use crate::model::{self, InvariantKind, TemperModel}; +use crate::diagnostic::{ + InvariantCapabilityError, runtime_enforcement_warnings_from_ioa, + unsupported_invariant_errors_from_ioa, +}; +use crate::model::{self, TemperModel}; use crate::proptest_gen::{self, PropTestResult}; use crate::simulation::{self, SimConfig, SimulationResult}; use crate::smt::{self, SmtResult}; @@ -86,8 +90,11 @@ pub struct CascadeResult { pub all_passed: bool, /// Per-level results. pub levels: Vec, - /// Warnings about invariants that could not be verified at model level. + /// Non-fatal cascade enrichment warnings. pub warnings: Vec, + /// Hard capability errors that prevented backend verification. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub errors: Vec, /// Reachable paths extracted after L1 model check (if path extraction was configured). #[serde(default, skip_serializing_if = "Option::is_none")] pub reachable_paths: Option, @@ -244,8 +251,21 @@ impl VerificationCascade { let mut levels = Vec::new(); let model = self.build_temper_model(); - // Collect warnings for Unverifiable invariants. - let mut warnings = collect_unverifiable_warnings(&model); + let errors = unsupported_invariant_errors_from_ioa(&self.ioa_source) + .expect("cascade IOA must retain invariant assertion source spans"); + if !errors.is_empty() { + return CascadeResult { + all_passed: false, + levels, + warnings: Vec::new(), + errors, + reachable_paths: None, + composite_report: None, + }; + } + + let mut warnings = runtime_enforcement_warnings_from_ioa(&self.ioa_source) + .expect("cascade IOA must build runtime enforcement disclosures"); // Level 0: SMT symbolic verification let l0 = self.run_symbolic_verification(); @@ -256,6 +276,7 @@ impl VerificationCascade { all_passed: false, levels, warnings, + errors: Vec::new(), reachable_paths: None, composite_report: None, }; @@ -270,6 +291,7 @@ impl VerificationCascade { all_passed: false, levels, warnings, + errors: Vec::new(), reachable_paths: None, composite_report: None, }; @@ -293,6 +315,7 @@ impl VerificationCascade { all_passed: false, levels, warnings, + errors: Vec::new(), reachable_paths, composite_report: None, }; @@ -309,6 +332,7 @@ impl VerificationCascade { composite_report: None, levels, warnings, + errors: Vec::new(), reachable_paths, }; } @@ -332,6 +356,7 @@ impl VerificationCascade { all_passed, levels, warnings, + errors: Vec::new(), reachable_paths, composite_report, } @@ -571,24 +596,6 @@ impl VerificationCascade { } } -/// Collect warnings for invariants classified as `Unverifiable`. -fn collect_unverifiable_warnings(model: &TemperModel) -> Vec { - model - .invariants - .iter() - .filter_map(|inv| { - if let InvariantKind::Unverifiable { expression } = &inv.kind { - Some(format!( - "invariant '{}' has unverifiable assertion '{}' — skipped at model level", - inv.name, expression, - )) - } else { - None - } - }) - .collect() -} - /// Build a [`CompositeCascadeReport`] from the configured scope, appending /// any build-time warnings (e.g. missing seed) to the cascade's warning /// list. Returns `None` if the plan cannot be built — the cascade still @@ -622,11 +629,11 @@ fn build_composite_report( mod tests { use super::*; - const ORDER_IOA: &str = include_str!("../../../test-fixtures/specs/order.ioa.toml"); + const VERIFIED_IOA: &str = include_str!("../../../test-fixtures/specs/directory.ioa.toml"); #[test] fn test_full_cascade_passes_ioa() { - let cascade = VerificationCascade::from_ioa(ORDER_IOA) + let cascade = VerificationCascade::from_ioa(VERIFIED_IOA) .with_sim_seeds(5) .with_prop_test_cases(100); @@ -640,7 +647,7 @@ mod tests { #[test] fn test_cascade_has_all_levels() { - let cascade = VerificationCascade::from_ioa(ORDER_IOA) + let cascade = VerificationCascade::from_ioa(VERIFIED_IOA) .with_sim_seeds(3) .with_prop_test_cases(50); @@ -658,7 +665,7 @@ mod tests { #[test] fn test_cascade_level_summaries() { - let cascade = VerificationCascade::from_ioa(ORDER_IOA) + let cascade = VerificationCascade::from_ioa(VERIFIED_IOA) .with_sim_seeds(3) .with_prop_test_cases(50); @@ -684,26 +691,145 @@ mod tests { } #[test] - fn test_cascade_warnings_for_unverifiable_invariants() { - let cascade = VerificationCascade::from_ioa(ORDER_IOA) + fn cascade_returns_structured_error_for_unverifiable_invariant() { + let spec = r#" +[automaton] +name = "Order" +states = ["Draft", "Shipped"] +initial = "Draft" + +[[invariant]] +name = "ShipRequiresPayment" +when = ["Shipped"] +assert = "payment_captured" +"#; + let cascade = VerificationCascade::from_ioa(spec) .with_sim_seeds(3) .with_prop_test_cases(50); let result = cascade.run(); - // Order spec has "payment_captured" which is not a declared bool, - // so ShipRequiresPayment becomes Unverifiable. + assert!(!result.all_passed); assert!( - !result.warnings.is_empty(), - "Should have warnings for unverifiable invariants" + result.levels.is_empty(), + "backends must not run after capability failure" ); + assert!(result.warnings.is_empty()); + let error = result.errors.first().expect("capability error"); + assert_eq!(error.code, "TVE001"); + assert_eq!(error.invariant, "ShipRequiresPayment"); + assert_eq!(error.assertion, "payment_captured"); + assert_eq!( + &spec[error.source_span.start.byte..error.source_span.end.byte], + "payment_captured" + ); + } + + #[test] + fn unsupported_safety_invariant_cannot_pass_cascade() { + let spec = r#" +[automaton] +name = "Workspace" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "used_bytes" +type = "counter" +initial = "0" + +[[state]] +name = "quota_limit" +type = "counter" +initial = "1" + +[[invariant]] +name = "WithinQuota" +when = ["Active"] +assert = "used_bytes ** quota_limit" +"#; + + let result = VerificationCascade::from_ioa(spec) + .with_sim_seeds(1) + .with_prop_test_cases(1) + .run(); + assert!( - result - .warnings - .iter() - .any(|w| w.contains("ShipRequiresPayment")), - "Should warn about ShipRequiresPayment, got: {:?}", - result.warnings, + !result.all_passed, + "unsupported safety invariants must fail closed: {result:#?}" + ); + let error = result.errors.first().expect("capability error"); + assert_eq!(error.code, "TVE001"); + assert_eq!(error.invariant, "WithinQuota"); + assert_eq!(error.assertion, "used_bytes ** quota_limit"); + assert_eq!(error.source_span.start.line, 21); + assert_eq!(error.source_span.start.column, 11); + assert_eq!( + &spec[error.source_span.start.byte..error.source_span.end.byte], + "used_bytes ** quota_limit" + ); + assert!(result.levels.is_empty()); + assert!(result.warnings.is_empty()); + } + + #[test] + fn capability_preflight_rejects_unsupported_invariant_before_reachability() { + let spec = r#" +[automaton] +name = "UnreachableClaim" +states = ["Active", "Unreachable"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[invariant]] +name = "UnsupportedEvenWhenUnreachable" +when = ["Unreachable"] +assert = "left_counter <= right_counter" +"#; + + let result = VerificationCascade::from_ioa(spec) + .with_sim_seeds(0) + .with_prop_test_cases(0) + .run(); + + assert!(!result.all_passed); + assert!(result.levels.is_empty()); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].invariant, "UnsupportedEvenWhenUnreachable"); + } + + #[test] + fn runtime_enforced_invariant_is_disclosed_without_claiming_model_proof() { + let spec = r#" +[automaton] +name = "Goal" +states = ["Draft"] +initial = "Draft" +allow_indefinite_states = ["Draft"] + +[[state]] +name = "goal" +type = "string" +initial = "" + +[[invariant]] +name = "GoalRequired" +when = ["Draft"] +assert = "goal != ''" +"#; + let result = VerificationCascade::from_ioa(spec) + .with_sim_seeds(1) + .with_prop_test_cases(1) + .run(); + + assert!( + result.all_passed, + "runtime contract is deployable: {result:#?}" ); + assert!(result.errors.is_empty()); + assert!(result.warnings.iter().any(|warning| { + warning.contains("GoalRequired") && warning.contains("not model-proved") + })); } #[test] @@ -743,7 +869,7 @@ guard = "count > 9" #[test] fn test_no_fail_fast_runs_all_levels() { - let cascade = VerificationCascade::from_ioa(ORDER_IOA) + let cascade = VerificationCascade::from_ioa(VERIFIED_IOA) .with_sim_seeds(3) .with_prop_test_cases(50); @@ -812,7 +938,7 @@ to = "Authorized" #[test] fn cascade_without_composite_scope_has_none_report() { - let cascade = VerificationCascade::from_ioa(ORDER_IOA) + let cascade = VerificationCascade::from_ioa(VERIFIED_IOA) .with_sim_seeds(2) .with_prop_test_cases(10); let result = cascade.run(); diff --git a/crates/temper-verify/src/composite/invariant_eval.rs b/crates/temper-verify/src/composite/invariant_eval.rs index abf418a76..b03a901ce 100644 --- a/crates/temper-verify/src/composite/invariant_eval.rs +++ b/crates/temper-verify/src/composite/invariant_eval.rs @@ -10,15 +10,20 @@ //! that are directly checkable on a single-entity `TemperModelState`: //! `StatusInSet`, `CounterPositive`, `NeverState`, `BoolRequired`, //! `NoReachingState`, `NoFurtherTransitions`. `Unverifiable` invariants -//! are treated as true (the single-entity cascade issues a warning; -//! the composite checker inherits that warning via the plan's -//! warnings vector). +//! fail closed so a composite proof cannot silently omit a safety claim. use crate::model::{InvariantKind, TemperModel, TemperModelState}; /// Evaluate every invariant on `model` against `state` (a single /// entity's slice of the joint state). Returns `true` iff all pass. pub(super) fn all_local_invariants_hold(model: &TemperModel, state: &TemperModelState) -> bool { + if model + .invariants + .iter() + .any(|invariant| matches!(invariant.kind, InvariantKind::Unverifiable { .. })) + { + return false; + } for inv in &model.invariants { if !triggers_on(&inv.trigger_states, &state.status) { continue; @@ -66,7 +71,8 @@ fn evaluate_one(kind: &InvariantKind, state: &TemperModelState) -> bool { } InvariantKind::And(kinds) => kinds.iter().all(|k| evaluate_one(k, state)), InvariantKind::Or(kinds) => kinds.iter().any(|k| evaluate_one(k, state)), - InvariantKind::Unverifiable { .. } => true, // warning issued elsewhere + InvariantKind::RuntimeEnforced(_) => true, + InvariantKind::Unverifiable { .. } => false, } } @@ -129,4 +135,23 @@ to = "B" assert!(all_local_invariants_hold(&model, &state("A"))); assert!(all_local_invariants_hold(&model, &state("B"))); } + + #[test] + fn unsupported_invariant_fails_composite_evaluation() { + let spec = r#" +[automaton] +name = "Workspace" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[invariant]] +name = "Unsupported" +when = ["NeverReached"] +assert = "left_counter <= right_counter" +"#; + let model = build(spec); + + assert!(!all_local_invariants_hold(&model, &state("Active"))); + } } diff --git a/crates/temper-verify/src/diagnostic.rs b/crates/temper-verify/src/diagnostic.rs new file mode 100644 index 000000000..21dc65584 --- /dev/null +++ b/crates/temper-verify/src/diagnostic.rs @@ -0,0 +1,240 @@ +//! Structured verification diagnostics emitted before backend execution. + +use crate::model::{InvariantKind, TemperModel}; +use temper_spec::automaton::{ + Automaton, SourceSpan, compile_runtime_invariants, parse_automaton, + unsupported_safety_invariant_names, +}; + +/// Stable diagnostic code for an invariant outside the verifier's typed IR. +pub const UNSUPPORTED_INVARIANT_CODE: &str = "TVE001"; + +/// A safety-invariant capability error that blocks the verification cascade. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct InvariantCapabilityError { + /// Stable machine-readable error code. + pub code: String, + /// Declared invariant name. + pub invariant: String, + /// Unsupported assertion expression. + pub assertion: String, + /// Exact half-open source range of `assertion`, excluding TOML quotes. + pub source_span: SourceSpan, +} + +/// Parse an IOA source and collect every unsupported invariant in declaration order. +/// +/// This is the cheap, deterministic capability gate used before cached or trusted +/// verification artifacts are accepted. Missing locations are returned as model +/// construction errors rather than panicking or emitting approximate diagnostics. +pub fn unsupported_invariant_errors_from_ioa( + ioa_source: &str, +) -> Result, String> { + let automaton = parse_diagnostic_automaton(ioa_source)?; + let model = crate::model::build_model_from_automaton(&automaton, 0); + unsupported_invariant_errors_with_automaton(&model, &automaton) +} + +/// Return explicit disclosures for safety assertions enforced only at runtime. +/// +/// Cached verification results call this independently so skipping the cascade +/// cannot erase the distinction between runtime enforcement and model proof. +pub fn runtime_enforcement_warnings_from_ioa(ioa_source: &str) -> Result, String> { + let automaton = parse_diagnostic_automaton(ioa_source)?; + Ok(runtime_enforcement_warnings_from_automaton(&automaton)) +} + +fn parse_diagnostic_automaton(ioa_source: &str) -> Result { + parse_automaton(ioa_source) + .map_err(|error| format!("failed to parse I/O Automaton TOML: {error}")) +} + +fn runtime_enforcement_warnings_from_automaton(automaton: &Automaton) -> Vec { + compile_runtime_invariants(automaton) + .into_iter() + .map(|invariant| runtime_enforcement_warning(&invariant.name)) + .collect() +} + +fn runtime_enforcement_warning(invariant_name: &str) -> String { + format!( + "invariant '{invariant_name}' is enforced by runtime safety contract version {}, not model-proved", + temper_spec::automaton::RUNTIME_INVARIANT_ENFORCEMENT_VERSION + ) +} + +fn unsupported_invariant_errors_with_automaton( + model: &TemperModel, + automaton: &Automaton, +) -> Result, String> { + let runtime_unsupported = unsupported_safety_invariant_names(automaton); + let mut errors = Vec::new(); + for declaration in &automaton.invariants { + let mut matches = model.invariants.iter().filter(|resolved| { + resolved.name == declaration.name && resolved.source_span == declaration.assert_span + }); + let resolved = matches.next().ok_or_else(|| { + format!( + "verification model did not retain declared safety invariant '{}'", + declaration.name + ) + })?; + if matches.next().is_some() { + return Err(format!( + "verification model retained multiple copies of declared safety invariant '{}'", + declaration.name + )); + } + let model_unsupported = matches!(resolved.kind, InvariantKind::Unverifiable { .. }); + let runtime_unsupported = runtime_unsupported + .iter() + .any(|name| name == &declaration.name); + if model_unsupported || runtime_unsupported { + let source_span = declaration.assert_span.ok_or_else(|| { + format!( + "unsupported IOA invariant '{}' is missing its assertion source span", + declaration.name + ) + })?; + errors.push(InvariantCapabilityError { + code: UNSUPPORTED_INVARIANT_CODE.to_string(), + invariant: declaration.name.clone(), + assertion: declaration.assert.clone(), + source_span, + }); + } + } + Ok(errors) +} + +#[cfg(test)] +mod tests { + use crate::VerificationCascade; + + const UNSUPPORTED_IOA: &str = r#" +[automaton] +name = "Workspace" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "used_bytes" +type = "counter" +initial = "0" + +[[state]] +name = "quota_limit" +type = "counter" +initial = "1" + +[[invariant]] +name = "WithinQuota" +when = ["Active"] +assert = "used_bytes ** quota_limit" +"#; + + #[test] + fn every_verification_backend_rejects_unsupported_ir_with_zero_budget() { + let model = crate::model::build_model_from_ioa(UNSUPPORTED_IOA, 2).expect("build model"); + + let symbolic = crate::smt::verify_symbolic(UNSUPPORTED_IOA, 2); + assert!(!symbolic.all_passed); + + let checked = crate::checker::check_model(&model); + assert!(!checked.all_properties_hold); + assert!( + checked + .counterexamples + .iter() + .any(|counterexample| counterexample.property == "InvariantCapability") + ); + + let simulation = crate::simulation::run_simulation_from_ioa( + UNSUPPORTED_IOA, + &crate::simulation::SimConfig { + max_ticks: 0, + num_actors: 0, + max_actions_per_actor: 0, + ..crate::simulation::SimConfig::default() + }, + ) + .expect("run simulation"); + assert!(!simulation.all_invariants_held); + + let multi_seed = crate::simulation::run_multi_seed_simulation_from_ioa( + UNSUPPORTED_IOA, + &crate::simulation::SimConfig::default(), + 0, + ); + assert!(multi_seed.is_err()); + + let property_tests = crate::proptest_gen::run_prop_tests_from_ioa(UNSUPPORTED_IOA, 0, 0); + assert!(!property_tests.passed); + } + + #[test] + fn capability_preflight_rejects_undeclared_counter_references() { + for assertion in ["missing > 0", "missing >= 0"] { + let spec = format!( + r#" +[automaton] +name = "UndeclaredCounterClaim" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[invariant]] +name = "CounterMustBeDeclared" +when = ["Active"] +assert = "{assertion}" +"# + ); + + let result = VerificationCascade::from_ioa(&spec) + .with_sim_seeds(0) + .with_prop_test_cases(0) + .run(); + + assert!(!result.all_passed, "{assertion} must fail closed"); + assert!(result.levels.is_empty()); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].code, "TVE001"); + assert_eq!(result.errors[0].assertion, assertion); + } + } + + #[test] + fn capability_preflight_rejects_undeclared_status_references() { + for (when, assertion) in [("TypoState", "true"), ("Active", "never(TypoState)")] { + let spec = format!( + r#" +[automaton] +name = "UndeclaredStatusClaim" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[invariant]] +name = "StatusMustBeDeclared" +when = ["{when}"] +assert = "{assertion}" +"# + ); + + let result = VerificationCascade::from_ioa(&spec) + .with_sim_seeds(0) + .with_prop_test_cases(0) + .run(); + + assert!( + !result.all_passed, + "when={when}, assert={assertion} must fail closed" + ); + assert!(result.levels.is_empty()); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].code, "TVE001"); + assert_eq!(result.errors[0].assertion, assertion); + } + } +} diff --git a/crates/temper-verify/src/lib.rs b/crates/temper-verify/src/lib.rs index 50f65465a..4b1399acf 100644 --- a/crates/temper-verify/src/lib.rs +++ b/crates/temper-verify/src/lib.rs @@ -9,6 +9,7 @@ pub mod cascade; pub mod checker; pub mod composite; +pub mod diagnostic; pub mod model; pub mod paths; pub mod proptest_gen; @@ -19,6 +20,10 @@ pub mod smt; pub use cascade::{ActorSimResult, CascadeLevel, CascadeResult, LevelResult, VerificationCascade}; pub use checker::{VerificationResult, check_model}; pub use composite::{CompositePlanError, CompositeVerificationPlan}; +pub use diagnostic::{ + InvariantCapabilityError, UNSUPPORTED_INVARIANT_CODE, runtime_enforcement_warnings_from_ioa, + unsupported_invariant_errors_from_ioa, +}; pub use model::{ InvariantKind, ModelEffect, ModelGuard, ResolvedTransition, TemperModel, TemperModelAction, TemperModelState, build_model_from_ioa, diff --git a/crates/temper-verify/src/model/builder.rs b/crates/temper-verify/src/model/builder.rs index 37c5c8ae7..a8d364eb4 100644 --- a/crates/temper-verify/src/model/builder.rs +++ b/crates/temper-verify/src/model/builder.rs @@ -5,7 +5,7 @@ //! Schedule, Spawn) are filtered out; CrossEntityState guards are kept as abstract //! guards so single-entity checks do not silently treat them as locally enabled. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use temper_spec::automaton::{ Automaton, ParsedAssert, ResolvedEffect, ResolvedGuard, parse_assert_expr, parse_bool_initial, @@ -163,7 +163,7 @@ fn convert_effect(effect: ResolvedEffect) -> ModelEffect { /// /// Uses [`parse_assert_expr`] from `temper-spec` as the primary classifier, /// then falls back to known boolean variable names. Unrecognized expressions -/// become `Unverifiable` (with a warning) instead of silently passing. +/// become `Unverifiable`, which every verification backend rejects. /// /// A `TypeInvariant` (StatusInSet) is always auto-included. fn resolve_invariants(automaton: &Automaton) -> Vec { @@ -175,30 +175,69 @@ fn resolve_invariants(automaton: &Automaton) -> Vec { trigger_states: vec![], required_states: vec![], kind: InvariantKind::StatusInSet, + source_span: None, }); // Collect known boolean variable names for fallback classification - let bool_names: Vec<&str> = automaton + let bool_names: BTreeSet = automaton .state .iter() .filter(|s| s.var_type == "bool") - .map(|s| s.name.as_str()) + .map(|s| s.name.clone()) .collect(); + let counter_names: BTreeSet = automaton + .state + .iter() + .filter(|state| state.var_type == "counter") + .map(|state| state.name.clone()) + .collect(); + let string_names: BTreeSet = automaton + .state + .iter() + .filter(|state| state.var_type == "string") + .map(|state| state.name.clone()) + .collect(); + let status_names: BTreeSet = automaton.automaton.states.iter().cloned().collect(); for inv in &automaton.invariants { let expr = inv.assert.trim(); - let kind = match parse_assert_expr(expr) { - Some(parsed) => translate_parsed_assert(parsed, expr, &bool_names), - None => InvariantKind::Unverifiable { + let references_declared_trigger_states = + inv.when.iter().all(|state| status_names.contains(state)); + let kind = if !references_declared_trigger_states { + InvariantKind::Unverifiable { expression: expr.to_string(), - }, + } + } else { + match parse_assert_expr(expr) { + Some(parsed) + if parsed.is_supported_safety_assertion( + &bool_names, + &counter_names, + &string_names, + &status_names, + ) => + { + translate_parsed_assert( + parsed, + expr, + &bool_names, + &counter_names, + &string_names, + &status_names, + ) + } + Some(_) | None => InvariantKind::Unverifiable { + expression: expr.to_string(), + }, + } }; result.push(ResolvedInvariant { name: inv.name.clone(), trigger_states: inv.when.clone(), required_states: vec![], kind, + source_span: inv.assert_span, }); } @@ -211,8 +250,21 @@ fn resolve_invariants(automaton: &Automaton) -> Vec { /// as a `bool` in the automaton state; otherwise the whole expression falls /// through to `Unverifiable`. This preserves the pre-compound behavior of /// rejecting unknown identifiers rather than silently asserting on them. -fn translate_parsed_assert(parsed: ParsedAssert, raw: &str, bool_names: &[&str]) -> InvariantKind { - match try_translate(&parsed, bool_names) { +fn translate_parsed_assert( + parsed: ParsedAssert, + raw: &str, + bool_names: &BTreeSet, + counter_names: &BTreeSet, + string_names: &BTreeSet, + status_names: &BTreeSet, +) -> InvariantKind { + match try_translate( + &parsed, + bool_names, + counter_names, + string_names, + status_names, + ) { Some(kind) => kind, None => InvariantKind::Unverifiable { expression: raw.to_string(), @@ -220,22 +272,54 @@ fn translate_parsed_assert(parsed: ParsedAssert, raw: &str, bool_names: &[&str]) } } -fn try_translate(parsed: &ParsedAssert, bool_names: &[&str]) -> Option { +fn try_translate( + parsed: &ParsedAssert, + bool_names: &BTreeSet, + counter_names: &BTreeSet, + string_names: &BTreeSet, + status_names: &BTreeSet, +) -> Option { match parsed { - ParsedAssert::CounterPositive { var } => { + ParsedAssert::Always => Some(InvariantKind::And(Vec::new())), + ParsedAssert::CounterPositive { var } if counter_names.contains(var) => { Some(InvariantKind::CounterPositive { var: var.clone() }) } + ParsedAssert::CounterPositive { .. } => None, ParsedAssert::NoFurtherTransitions => Some(InvariantKind::NoFurtherTransitions), - ParsedAssert::NeverState { state } => Some(InvariantKind::NeverState { - state: state.clone(), - }), - ParsedAssert::CounterCompare { var, op, value } => Some(InvariantKind::CounterCompare { - var: var.clone(), - op: op.clone(), - value: *value, - }), + ParsedAssert::NeverState { state } if status_names.contains(state) => { + Some(InvariantKind::NeverState { + state: state.clone(), + }) + } + ParsedAssert::NeverState { .. } => None, + ParsedAssert::CounterCompare { var, op, value } if counter_names.contains(var) => { + Some(InvariantKind::CounterCompare { + var: var.clone(), + op: op.clone(), + value: *value, + }) + } + ParsedAssert::CounterCompare { .. } => None, + ParsedAssert::CounterVarCompare { left, op, right } + if counter_names.contains(left) && counter_names.contains(right) => + { + Some(InvariantKind::RuntimeEnforced( + temper_spec::automaton::RuntimeAssert::CounterVarCompare { + left: left.clone(), + op: op.clone(), + right: right.clone(), + }, + )) + } + ParsedAssert::CounterVarCompare { .. } => None, + ParsedAssert::StringNonEmpty { var } if string_names.contains(var) => { + Some(InvariantKind::RuntimeEnforced( + temper_spec::automaton::RuntimeAssert::StringNonEmpty { var: var.clone() }, + )) + } + ParsedAssert::StringNonEmpty { .. } => None, ParsedAssert::BoolRequired { var, expect } => { - if bool_names.contains(&var.as_str()) { + if bool_names.contains(var) { Some(InvariantKind::BoolRequired { var: var.clone(), expect: *expect, @@ -246,18 +330,40 @@ fn try_translate(parsed: &ParsedAssert, bool_names: &[&str]) -> Option None, ParsedAssert::And(parts) => { - let mapped: Option> = - parts.iter().map(|p| try_translate(p, bool_names)).collect(); - mapped.map(InvariantKind::And) + let mapped: Option> = parts + .iter() + .map(|part| { + try_translate(part, bool_names, counter_names, string_names, status_names) + }) + .collect(); + mapped.and_then(|kinds| { + (!kinds.iter().any(contains_runtime_enforced)).then_some(InvariantKind::And(kinds)) + }) } ParsedAssert::Or(parts) => { - let mapped: Option> = - parts.iter().map(|p| try_translate(p, bool_names)).collect(); - mapped.map(InvariantKind::Or) + let mapped: Option> = parts + .iter() + .map(|part| { + try_translate(part, bool_names, counter_names, string_names, status_names) + }) + .collect(); + mapped.and_then(|kinds| { + (!kinds.iter().any(contains_runtime_enforced)).then_some(InvariantKind::Or(kinds)) + }) } } } +fn contains_runtime_enforced(kind: &InvariantKind) -> bool { + match kind { + InvariantKind::RuntimeEnforced(_) => true, + InvariantKind::And(parts) | InvariantKind::Or(parts) => { + parts.iter().any(contains_runtime_enforced) + } + _ => false, + } +} + /// Translate IOA liveness properties into resolved liveness. fn resolve_liveness(automaton: &Automaton) -> Vec { automaton @@ -469,9 +575,11 @@ guard = [{ type = "cross_entity_state", entity_type = "Child", entity_id_source #[test] fn test_undeclared_bool_invariant_falls_back_to_unverifiable() { - // payment_captured is NOT declared as a [[state]] bool var in the spec, - // so "ShipRequiresPayment" falls back to Unverifiable (we can't model it). - let model = build_order_model(); + let ioa = ORDER_IOA.replace( + "assert = \"payment_captured\"", + "assert = \"undeclared_payment\"", + ); + let model = build_model_from_ioa(&ioa, 2).expect("build mutated model"); let ship_inv = model .invariants .iter() @@ -617,4 +725,37 @@ assert = "migrations_ok && undeclared_flag" "compound expression referencing an undeclared bool must fall back to Unverifiable" ); } + + #[test] + fn compound_runtime_assertion_is_not_accepted_without_composite_enforcement() { + let ioa = r#" +[automaton] +name = "Goal" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "goal" +type = "string" +initial = "" + +[[state]] +name = "ready" +type = "bool" +initial = "true" + +[[invariant]] +name = "CompositeRuntimeClaim" +when = ["Active"] +assert = "goal != '' && ready" +"#; + let model = build_model_from_ioa(ioa, 2).expect("build model"); + let invariant = model + .invariants + .iter() + .find(|invariant| invariant.name == "CompositeRuntimeClaim") + .expect("declared invariant"); + assert!(matches!(invariant.kind, InvariantKind::Unverifiable { .. })); + } } diff --git a/crates/temper-verify/src/model/stateright_impl.rs b/crates/temper-verify/src/model/stateright_impl.rs index 64b6dbc0a..fcceef20a 100644 --- a/crates/temper-verify/src/model/stateright_impl.rs +++ b/crates/temper-verify/src/model/stateright_impl.rs @@ -101,7 +101,8 @@ fn kind_holds( InvariantKind::Or(parts) => parts .iter() .any(|k| kind_holds(k, required_states, model, state)), - InvariantKind::Unverifiable { .. } => true, + InvariantKind::RuntimeEnforced(_) => true, + InvariantKind::Unverifiable { .. } => false, } } @@ -119,6 +120,14 @@ fn check_compound_invariants(model: &TemperModel, state: &TemperModelState) -> b true } +/// Reject a model containing any safety claim outside the shared typed IR. +fn check_invariant_capability(model: &TemperModel, _state: &TemperModelState) -> bool { + !model + .invariants + .iter() + .any(|invariant| matches!(invariant.kind, InvariantKind::Unverifiable { .. })) +} + /// Check all NoFurtherTransitions invariants: when status is in triggers, /// no actions should be enabled. fn check_no_further_transitions(model: &TemperModel, state: &TemperModelState) -> bool { @@ -434,7 +443,16 @@ impl Model for TemperModel { )); } - // Note: Unverifiable invariants generate no properties (skipped). + let has_unverifiable = self + .invariants + .iter() + .any(|i| matches!(i.kind, InvariantKind::Unverifiable { .. })); + if has_unverifiable { + props.push(Property::always( + "InvariantCapability", + check_invariant_capability, + )); + } // Liveness: NoDeadlock (expressed as safety: "always has actions") let has_no_deadlock = self diff --git a/crates/temper-verify/src/model/types.rs b/crates/temper-verify/src/model/types.rs index 4928f5ac6..927dd04bb 100644 --- a/crates/temper-verify/src/model/types.rs +++ b/crates/temper-verify/src/model/types.rs @@ -187,8 +187,10 @@ pub enum InvariantKind { And(Vec), /// Compound: at least one subexpression must hold. Or(Vec), - /// Assertion expression that cannot be verified at model level. - /// Surfaces as a warning in the cascade result. + /// Assertion enforced atomically by runtime contract version 2. + RuntimeEnforced(temper_spec::automaton::RuntimeAssert), + /// Assertion expression that cannot be represented by the verifier. + /// Every backend must treat this as a hard failure. Unverifiable { expression: String }, } @@ -203,6 +205,8 @@ pub struct ResolvedInvariant { pub required_states: Vec, /// The kind of check this invariant performs. pub kind: InvariantKind, + /// Exact source range of the declared assertion, when it came from IOA. + pub source_span: Option, } // --------------------------------------------------------------------------- diff --git a/crates/temper-verify/src/proptest_gen.rs b/crates/temper-verify/src/proptest_gen.rs index 37ddd1eae..8cb91a5dd 100644 --- a/crates/temper-verify/src/proptest_gen.rs +++ b/crates/temper-verify/src/proptest_gen.rs @@ -17,6 +17,8 @@ use crate::model::{ InvariantKind, TemperModel, TemperModelAction, TemperModelState, build_model_from_ioa, }; +mod capability; + // --------------------------------------------------------------------------- // Public result types // --------------------------------------------------------------------------- @@ -53,6 +55,9 @@ pub struct PropTestFailure { /// the first invariant that is violated. fn check_invariants(model: &TemperModel, state: &TemperModelState) -> Result<(), String> { for inv in &model.invariants { + if matches!(inv.kind, InvariantKind::Unverifiable { .. }) { + return Err(inv.name.clone()); + } let triggered = inv.trigger_states.is_empty() || inv.trigger_states.contains(&state.status); if !triggered { continue; @@ -116,7 +121,8 @@ fn kind_violated( InvariantKind::Or(parts) => parts .iter() .all(|k| kind_violated(k, required_states, model, state)), - InvariantKind::Unverifiable { .. } => false, + InvariantKind::RuntimeEnforced(_) => false, + InvariantKind::Unverifiable { .. } => true, } } @@ -146,6 +152,9 @@ pub fn run_prop_tests_on_model( ) -> PropTestResult { let init_states = model.init_states(); let init_state = &init_states[0]; + if let Some(result) = capability::failure(model, init_state) { + return result; + } for case_idx in 0..num_cases { let mut rng_state: u64 = case_idx.wrapping_mul(6364136223846793005).wrapping_add(1); @@ -227,6 +236,12 @@ pub fn run_prop_tests_with_shrinking_on_model( num_cases: u32, max_steps: usize, ) -> PropTestResult { + let init_states = model.init_states(); + let init_state = init_states[0].clone(); + if let Some(result) = capability::failure(model, &init_state) { + return result; + } + let config = ProptestConfig { cases: num_cases, ..ProptestConfig::default() @@ -235,9 +250,6 @@ pub fn run_prop_tests_with_shrinking_on_model( let mut runner = TestRunner::new(config); let strategy = proptest::collection::vec(0..1000usize, 0..=max_steps); - let init_states = model.init_states(); - let init_state = init_states[0].clone(); - let result = runner.run(&strategy, |action_indices| { let mut state = init_state.clone(); @@ -460,12 +472,14 @@ mod tests { trigger_states: vec![], required_states: vec![], kind: InvariantKind::StatusInSet, + source_span: None, }, ResolvedInvariant { name: "OnlyA".to_string(), trigger_states: vec!["B".to_string()], required_states: vec!["A".to_string()], kind: InvariantKind::Implication, + source_span: None, }, ], liveness: vec![], diff --git a/crates/temper-verify/src/proptest_gen/capability.rs b/crates/temper-verify/src/proptest_gen/capability.rs new file mode 100644 index 000000000..326094230 --- /dev/null +++ b/crates/temper-verify/src/proptest_gen/capability.rs @@ -0,0 +1,19 @@ +use super::{PropTestFailure, PropTestResult}; +use crate::model::{InvariantKind, TemperModel, TemperModelState}; + +pub(super) fn failure( + model: &TemperModel, + initial_state: &TemperModelState, +) -> Option { + model.invariants.iter().find_map(|invariant| { + matches!(invariant.kind, InvariantKind::Unverifiable { .. }).then(|| PropTestResult { + total_cases: 0, + passed: false, + failure: Some(PropTestFailure { + invariant: invariant.name.clone(), + action_sequence: Vec::new(), + final_state: format!("{initial_state}"), + }), + }) + }) +} diff --git a/crates/temper-verify/src/simulation.rs b/crates/temper-verify/src/simulation.rs index f68cb27a1..c95de4de6 100644 --- a/crates/temper-verify/src/simulation.rs +++ b/crates/temper-verify/src/simulation.rs @@ -143,6 +143,16 @@ pub fn run_multi_seed_simulation_from_ioa( num_seeds: u64, ) -> Result, String> { let model = build_model_from_ioa(ioa_toml, base_config.max_counter)?; + if let Some(invariant) = model + .invariants + .iter() + .find(|invariant| matches!(invariant.kind, InvariantKind::Unverifiable { .. })) + { + return Err(format!( + "unsupported safety invariant '{}': verification capability failure", + invariant.name + )); + } Ok((0..num_seeds) .map(|i| { let mut config = base_config.clone(); @@ -153,6 +163,32 @@ pub fn run_multi_seed_simulation_from_ioa( } fn run_simulation_impl(model: &TemperModel, config: &SimConfig) -> SimulationResult { + if let Some(invariant) = model + .invariants + .iter() + .find(|invariant| matches!(invariant.kind, InvariantKind::Unverifiable { .. })) + { + let initial = model.init_states()[0].clone(); + return SimulationResult { + all_invariants_held: false, + ticks: 0, + total_transitions: 0, + total_messages: 0, + total_dropped: 0, + violations: vec![InvariantViolation { + actor_id: "verification-capability".to_string(), + action: "preflight".to_string(), + state_before: initial.clone(), + state_after: initial.clone(), + invariant: invariant.name.clone(), + tick: 0, + }], + liveness_violations: Vec::new(), + seed: config.seed, + actor_final_states: vec![("verification-capability".to_string(), initial)], + }; + } + let mut sched = SimScheduler::new(config.seed, config.faults.clone()); let mut rng = DeterministicRng::new(config.seed.wrapping_add(1)); @@ -408,7 +444,8 @@ fn sim_kind_violated( InvariantKind::Or(parts) => parts .iter() .all(|k| sim_kind_violated(k, required_states, model, state_after)), - InvariantKind::Unverifiable { .. } => false, + InvariantKind::RuntimeEnforced(_) => false, + InvariantKind::Unverifiable { .. } => true, } } diff --git a/crates/temper-verify/src/smt.rs b/crates/temper-verify/src/smt.rs index 9311de62f..107de6eb5 100644 --- a/crates/temper-verify/src/smt.rs +++ b/crates/temper-verify/src/smt.rs @@ -248,9 +248,10 @@ fn check_invariant_induction(model: &TemperModel, max_counter: usize) -> Vec<(St // simulation (proptest_gen/simulation) catches violations. true } + InvariantKind::RuntimeEnforced(_) => true, InvariantKind::Unverifiable { .. } => { - // Not checkable at model level — trivially inductive. - true + // An unrepresentable safety claim can never count as proof. + false } }; @@ -301,7 +302,9 @@ fn kind_inductive_smt( InvariantKind::And(parts) => parts .iter() .all(|p| kind_inductive_smt(model, trigger_states, p, max_counter)), - InvariantKind::Or(_) | InvariantKind::Unverifiable { .. } => true, + InvariantKind::Or(_) => true, + InvariantKind::RuntimeEnforced(_) => true, + InvariantKind::Unverifiable { .. } => false, } } diff --git a/crates/temper-verify/tests/parameter_safety_diagnostics.rs b/crates/temper-verify/tests/parameter_safety_diagnostics.rs new file mode 100644 index 000000000..828627c7f --- /dev/null +++ b/crates/temper-verify/tests/parameter_safety_diagnostics.rs @@ -0,0 +1,116 @@ +use temper_verify::{VerificationCascade, runtime_enforcement_warnings_from_ioa}; + +#[test] +fn parameter_controlled_terminal_compound_fails_cascade_preflight() { + let spec = r#" +[automaton] +name = "ParameterTerminal" +states = ["Active", "Unlocked", "Done"] +initial = "Active" +allow_indefinite_states = ["Active", "Unlocked", "Done"] + +[[state]] +name = "budget" +type = "counter" +initial = "0" + +[[state]] +name = "safe" +type = "bool" +initial = "false" + +[[action]] +name = "SetBudget" +kind = "input" +from = ["Active"] +to = "Active" +params = ["budget"] +effect = [{ type = "set_counter_from_param", var = "budget", param = "budget" }] + +[[action]] +name = "Unlock" +kind = "input" +from = ["Active"] +to = "Unlocked" +guard = [{ type = "min_count", var = "budget", min = 2 }] + +[[action]] +name = "Finish" +kind = "input" +from = ["Unlocked"] +to = "Done" + +[[action]] +name = "Continue" +kind = "input" +from = ["Done"] +to = "Done" + +[[invariant]] +name = "DoneSafety" +when = ["Done"] +assert = "safe && no_further_transitions" +"#; + + let result = VerificationCascade::from_ioa(spec) + .with_sim_seeds(0) + .with_prop_test_cases(0) + .run(); + + assert!(!result.all_passed); + assert!( + result.levels.is_empty(), + "capability failure must precede proof" + ); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].code, "TVE001"); + assert_eq!(result.errors[0].invariant, "DoneSafety"); + assert_eq!(result.errors[0].assertion, "safe && no_further_transitions"); +} + +#[test] +fn parameter_derived_runtime_contract_is_disclosed_for_fresh_and_cached_paths() { + let spec = r#" +[automaton] +name = "Usage" +states = ["Active"] +initial = "Active" +allow_indefinite_states = ["Active"] + +[[state]] +name = "used" +type = "counter" +initial = "0" + +[[action]] +name = "SetUsage" +kind = "input" +from = ["Active"] +to = "Active" +params = ["amount"] +effect = [{ type = "set_counter_from_param", var = "used", param = "amount" }] + +[[invariant]] +name = "WithinLimit" +when = ["Active"] +assert = "used <= 5" +"#; + + let cached_warnings = + runtime_enforcement_warnings_from_ioa(spec).expect("cached disclosure preflight"); + assert!(cached_warnings.iter().any(|warning| { + warning.contains("WithinLimit") && warning.contains("not model-proved") + })); + + let result = VerificationCascade::from_ioa(spec) + .with_sim_seeds(0) + .with_prop_test_cases(0) + .run(); + assert!( + result.all_passed, + "runtime-enforced limit remains deployable" + ); + assert!(result.warnings.iter().any(|warning| { + warning.contains("WithinLimit") && warning.contains("not model-proved") + })); +} diff --git a/crates/temper-verify/tests/production_invariant_corpus.rs b/crates/temper-verify/tests/production_invariant_corpus.rs new file mode 100644 index 000000000..db08f6388 --- /dev/null +++ b/crates/temper-verify/tests/production_invariant_corpus.rs @@ -0,0 +1,141 @@ +//! Capability regression over every checked-in production IOA invariant. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use temper_verify::{InvariantKind, VerificationCascade, build_model_from_ioa}; + +#[test] +fn every_production_invariant_is_typed_or_rejected_with_its_exact_span() { + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut files = Vec::new(); + for root in ["crates", "os-apps", "reference-apps"] { + collect_ioa_files(&repo.join(root), &mut files); + } + files.sort(); + + let mut declarations = 0usize; + let mut supported = 0usize; + let mut unsupported = 0usize; + let mut forms = BTreeMap::::new(); + + for path in &files { + let source = std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + let model = build_model_from_ioa(&source, 2) + .unwrap_or_else(|error| panic!("build {}: {error}", path.display())); + let declared = &model.invariants[1..]; + declarations += declared.len(); + + let unsupported_here: Vec<_> = declared + .iter() + .filter_map(|invariant| { + let InvariantKind::Unverifiable { expression } = &invariant.kind else { + supported += 1; + return None; + }; + unsupported += 1; + Some((invariant.name.as_str(), expression.as_str())) + }) + .collect(); + + for invariant in declared { + let (expression, is_unsupported) = match &invariant.kind { + InvariantKind::Unverifiable { expression } => (expression.clone(), true), + _ => { + let span = invariant + .source_span + .expect("supported invariant source span"); + (source[span.start.byte..span.end.byte].to_string(), false) + } + }; + let entry = forms.entry(expression).or_insert((0, is_unsupported)); + entry.0 += 1; + assert_eq!( + entry.1, is_unsupported, + "the same assertion form cannot be both supported and unsupported" + ); + } + + if unsupported_here.is_empty() { + continue; + } + + let result = VerificationCascade::from_ioa(&source).run(); + assert!( + !result.all_passed, + "{} passed unsupported claims", + path.display() + ); + assert!( + result.levels.is_empty(), + "{} ran a backend after capability failure", + path.display() + ); + assert_eq!( + result.errors.len(), + unsupported_here.len(), + "{}", + path.display() + ); + for (error, (name, expression)) in result.errors.iter().zip(unsupported_here) { + assert_eq!(error.code, "TVE001"); + assert_eq!(error.invariant, name); + assert_eq!(error.assertion, expression); + assert_eq!( + &source[error.source_span.start.byte..error.source_span.end.byte], + expression, + "{} returned an inexact source range", + path.display() + ); + } + } + + assert_eq!(files.len(), 110, "production IOA corpus changed"); + assert_eq!(declarations, 120, "invariant declaration corpus changed"); + assert_eq!(supported, 120, "supported invariant corpus changed"); + assert_eq!(unsupported, 0, "rejected invariant corpus changed"); + assert_eq!(forms.len(), 53, "distinct assertion-form corpus changed"); + + let rejected: BTreeMap<_, _> = forms + .iter() + .filter(|(_, (_, is_rejected))| *is_rejected) + .map(|(form, (count, _))| (form.as_str(), *count)) + .collect(); + let expected_rejected = BTreeMap::new(); + assert_eq!( + rejected, expected_rejected, + "rejected assertion forms changed" + ); + eprintln!( + "production invariant corpus: {} specs, {declarations} declarations, {supported} supported, {unsupported} rejected, {} distinct forms", + files.len(), + forms.len() + ); + for (form, (count, rejected)) in forms { + eprintln!( + "{count:>3} {} {form}", + if rejected { "REJECT" } else { "TYPED " } + ); + } +} + +fn collect_ioa_files(directory: &Path, files: &mut Vec) { + if !directory.exists() { + return; + } + let entries = std::fs::read_dir(directory) + .unwrap_or_else(|error| panic!("read directory {}: {error}", directory.display())); + for entry in entries { + let entry = entry.expect("read directory entry"); + let path = entry.path(); + if path.is_dir() { + collect_ioa_files(&path, files); + } else if path + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with(".ioa.toml")) + { + files.push(path); + } + } +} diff --git a/docs/adrs/0190-unsupported-safety-invariants-fail-closed.md b/docs/adrs/0190-unsupported-safety-invariants-fail-closed.md new file mode 100644 index 000000000..02d9ffec2 --- /dev/null +++ b/docs/adrs/0190-unsupported-safety-invariants-fail-closed.md @@ -0,0 +1,260 @@ +# ADR-0190: Unsupported safety invariants fail closed + +- Status: Proposed +- Date: 2026-07-14 +- Deciders: Temper core maintainers +- Supersedes: ADR-0016 Sub-Decision 2 (warning-only handling for unverifiable assertions) +- Related: + - ADR-0016: Verification Cascade Hardening + - `crates/temper-spec/src/automaton/assert_parser.rs` (shared typed assertion parser) + - `crates/temper-verify/src/model/builder.rs` (verification capability classification) + - `crates/temper-verify/src/cascade.rs` (deployment-facing verification result) + - `crates/temper-runtime/src/scheduler/sim_handler.rs` (runtime assertion evaluation) + - `crates/temper-server/src/entity_actor/sim_handler.rs` (actor-simulation adapter) + - `crates/temper-platform/src/deploy.rs` (cached deployment verification gate) + - `crates/temper-platform/src/bootstrap.rs` (cached and trusted bootstrap gate) + +## Context + +ADR-0016 replaced an implicit, vacuous invariant fallback with an explicit +`InvariantKind::Unverifiable`, but deliberately made that classification a warning. The +verification backends all permit it through different mechanisms: symbolic and +exhaustive model checking treat it as true or omit it, model-level simulation and +property testing return `false` from their "is violated" predicates, and actor simulation +filters unsupported forms out of its runtime assertion set. Consequently, every level +can report success even though the verifier knows before exploration that it cannot +prove a declared safety property. + +This behavior is present in checked-in application specifications. For example, +`os-apps/temper-fs/specs/workspace.ioa.toml` compares one counter to another +(`used_bytes <= quota_limit`), while the shared typed assertion parser currently supports +counter-to-literal comparisons only. A cascade can therefore report success without +checking the declaration that consumers believe was model-checked. + +Temper already has one typed assertion parser in `temper-spec`. The missing abstractions +are an explicit capability gate between parsed safety intent and the verification +backends, and a first-class classification for assertions whose safety is guaranteed by +the actor commit boundary rather than by state-space proof. + +## Decision + +### Sub-Decision 1: Unsupported means verification failure + +`InvariantKind::Unverifiable` remains the model-builder representation for an assertion +that the shared `ParsedAssert` grammar or the verifier's typed translation cannot +support. It is a hard verification failure everywhere it can be observed: + +- the cascade cannot set `all_passed = true` when any invariant is unverifiable; +- symbolic induction reports the invariant as not proved; +- exhaustive and composite model-check evaluation must not treat it as true; +- simulation and property testing reject unsupported declarations even when their case, + tick, actor, or seed budget is zero; +- actor assertion extraction retains an explicit unsupported sentinel, and runtime + evaluation treats that sentinel as a violation if a direct actor-simulation caller + bypasses the cascade; +- cached verification results and prebuilt trust bundles remain subject to the same + cheap capability preflight before they can skip the full cascade. + +The cascade decides this before relying on state reachability, action generation, seed +selection, or fault scheduling. Unsupported safety intent is a verifier capability +error, not a state-dependent counterexample. + +**Why this approach**: A declared safety property has only two truthful successful +outcomes: it was proved by a supported verifier encoding, or it was explicitly assigned +to another tested enforcement mechanism. Anything else blocks deployment. + +### Sub-Decision 2: Keep one typed assertion parser + +The existing `temper_spec::automaton::ParsedAssert` remains the canonical syntax IR +shared by verification and runtime assertion consumers. This change does not add a +second parser or infer meaning from assertion strings in the cascade. The model builder +continues to translate supported `ParsedAssert` variants into `InvariantKind`; failure to +parse or translate produces `Unverifiable` with the original expression. + +**Why this approach**: Parser duplication would allow verification and runtime semantics +to diverge again. Capability rejection belongs after the shared parse and typed +translation, where the verifier can state exactly what it cannot encode. + +The existing production spellings `is_true ` and `true` are normalized +within that parser to the already-supported boolean requirement and empty +conjunction IR respectively. They are typed aliases, not capability exemptions. + +### Sub-Decision 3: Structured diagnostics identify the declaration + +`CascadeResult` exposes structured unsupported-invariant diagnostics in addition to its +human-readable summary. Each diagnostic contains a stable error code, invariant name, +original assertion, and the source range in the submitted IOA document as byte offsets +and one-based line/column positions. Source ranges are derived from the original TOML +document; they are not reconstructed from normalized model strings. + +Warnings remain available for non-fatal advisory information, but unsupported safety +assertions are errors and are not described as skipped warnings. + +**Why this approach**: Callers need a machine-readable deployment gate, while developers +need to locate the exact declaration without searching a generated specification. + +### Sub-Decision 4: Typed runtime-enforced assertions have an atomic contract + +The shared parser recognizes two checked-in forms that the bounded model does not encode: +non-empty strings (`field != ''`) and counter-to-counter comparisons. The model builder +classifies these as `RuntimeEnforced(RuntimeAssert)`, distinct from both model-proved +assertions and `Unverifiable`. Each compiled `TransitionTable` carries the typed +`RuntimeInvariant` values and enforcement contract version 2. + +The entity handler evaluates these assertions on tentative post-transition state after +effects, status fallback, and action-parameter field synchronization, but before event +construction, persistence, custom-effect publication, or scheduled-action publication. +Initial entity fields, direct field updates, and deletion use the same gate. A failure +restores the complete pre-mutation state and returns an error. The same typed evaluator +is used by production and deterministic simulation. Hydration validates loaded snapshots +and evaluates every replayed event, including tombstones, before making recovered state +available; a violation fails recovery rather than serving invalid durable state. +Declared counter, boolean, and string initial values are compiled into the transition +table so every creation path starts from the same logical state. Oversized non-empty +strings retain their logical meaning when projected as content-addressed blob references. +Checked counter addition rejects numeric overflow and restores the whole pre-action state. +Creation, action, direct-update, and replay payloads are type-checked against declared +state variables before evaluation, so callers cannot impersonate an internal blob +envelope or silently retain a default after submitting an invalid counter. JIT, verifier, +production, and simulation use the same bool/counter initial-value parsers. + +A registry hot swap may retain durable snapshots, event tails, projected rows, and live +actors. Therefore a change to an existing entity type's runtime-invariant contract is +rejected atomically until an explicit migration has validated the existing state; the +old CSDL, source, and table remain active. The alternate Postgres actor runtime rejects +runtime-enforced specs at startup until it implements the same atomic contract. + +`true` and `is_true ` remain model-proved aliases. Unknown expressions never become +runtime-enforced implicitly: only the closed `RuntimeAssert` enum qualifies, and all +other untranslatable syntax is `Unverifiable`/`TVE001`. +Successful cascade results disclose every runtime-enforced declaration as a non-fatal +warning stating its contract version and that it was not model-proved. + +**Why this approach**: These safety claims concern complete entity data that the current +abstract model intentionally does not carry. Enforcing them at the atomic commit boundary +preserves the working applications without pretending the model proved data it omitted. +The typed, versioned contract makes the enforcement owner and lifecycle explicit. + +## Rollout Plan + +1. Add a behavioral regression showing that an unsupported invariant deterministically + makes the cascade fail, independent of reachability and simulation seeds. +2. Add structured source diagnostics and the cascade capability gate. +3. Align direct symbolic, exhaustive, and composite evaluation with fail-closed + semantics. +4. Compile typed runtime assertions into `TransitionTable` and enforce them atomically in + live action handling, deterministic simulation, full replay, and snapshot hydration. +5. Invalidate old cached/trusted false passes with a source capability preflight before + every deployment or bootstrap cache decision. +6. Reject runtime-contract hot swaps without an explicit durable-state validation and + migration, leaving the previous registry configuration untouched. +7. Run the checked-in IOA corpus and prove every declaration is either model-proved or + attached to runtime enforcement contract version 2; do not weaken or delete claims. + +## Readiness Gates + +- No cascade containing `InvariantKind::Unverifiable` reports `all_passed = true`. +- Diagnostics include the invariant name, assertion text, and exact source range. +- Every model-proved invariant form retains its existing verification behavior. +- Runtime-enforced declarations reject invalid live actions and invalid durable replay. +- All 120 checked-in declarations have a typed model or runtime-enforced classification. +- Unchanged cached specs and trusted bundles cannot bypass the capability gate. +- Cached results retain the disclosure that runtime-enforced claims were not model-proved. +- Existing entity types cannot activate a changed runtime contract without durable-state + validation and migration. +- Direct backend entry points reject unsupported declarations at zero exploration budget. +- Actor simulation reports a truly unsupported sentinel as an invariant violation, while + runtime-enforced forms execute the same evaluator and handler path as production. + +## Consequences + +### Positive + +- Verification success once again means every declared safety invariant was understood + by the verifier. +- Failure no longer depends on whether randomized or exhaustive execution reaches an + invariant's trigger state. +- Tooling receives stable, source-addressable diagnostics. +- Parser, runtime, simulation, and replay share one typed assertion grammar and evaluator. +- Verification cache durability no longer preserves pass artifacts issued under the old + warning-only capability policy. + +### Negative + +- Existing specifications with truly unknown assertions stop deploying until a typed + model-proof or runtime-enforcement encoding is added. +- `CascadeResult` gains a structured diagnostic field that serializers and UIs may + choose to display. +- Public assertion/model structures and the runtime `SpecAssert` enum gain source or + enforcement fields/variants. This is a deliberate source-level API change for + exhaustive downstream struct literals and matches. Serialized transition tables that + predate the runtime-contract field are rejected and must be rebuilt from source. +- Runtime enforcement adds one deterministic check. Action execution retains a + pre-action state snapshot so both safety rejection and checked-arithmetic failure can + roll back every tentative effect. + +### Risks + +- A production spec may have relied on truly unknown false-success behavior. This is an + intentional compatibility break: retaining deployment for an unchecked safety claim + would preserve the defect. +- Source-location extraction could drift from the hand-rolled automaton parser. + Diagnostics therefore use TOML array-table spans from the same submitted document and + are covered by double-quoted, single-quoted, CRLF, inline-comment, and repeated-invariant + tests. A missing source span is returned as an error by the public diagnostic API rather + than panicking. +- Old persisted verification rows do not carry a capability-policy version. Deployment + and bootstrap therefore rerun the cheap typed capability preflight before consulting + hash caches or bundle trust; only supported specs retain the performance benefit. +- A future runtime assertion semantic change could invalidate compiled artifacts. Every + runtime assertion therefore carries enforcement version 2, deployment/bootstrap + rebuild tables from source after capability preflight, and deserialization rejects + pre-contract tables that lack the runtime-invariant field. +- Runtime contracts cannot be introduced or changed through ordinary hot swap for an + existing entity type. Operators must run an explicit state-validation migration first; + this is intentionally conservative because the synchronous registry swap has no safe + way to enumerate every durable backend and live actor atomically. + +### DST Compliance + +This change touches simulation-visible `temper-runtime`, `temper-jit`, and `temper-server` +code. Runtime adds explicit `RuntimeEnforced` and `Unsupported` assertion variants. The +former is enforced inside the same real entity handler used by production; the latter is +a pure deterministic failure. Fixed seed 213 exercises atomic rejection and rollback for +string and counter assertions, counter overflow, initial-state validation, and deletion. +Replay injects invalid snapshots, event tails, and tombstones and proves recovery fails. +Existing fault-scheduled simulation covers the unsupported sentinel. The change +adds no clock, ambient I/O, thread, or unordered collection, and the capability gate and +runtime evaluator are pure deterministic functions. The DST reviewer must pass the final +diff before commit. + +## Non-Goals + +- Rewriting checked-in application safety declarations to narrower assertions. +- Treating arbitrary unparsed expressions as runtime-enforced. +- Changing action, guard, liveness, or field-invariant grammar. + +## Alternatives Considered + +1. **Keep warnings and require callers to inspect them** — Rejected because existing + deployment callers correctly use `all_passed` as the gate; advisory text must not + override a successful machine-readable result. +2. **Rely on simulation/property exploration to expose the gap** — Rejected because + verifier capability is known before exploration, while the existing unsupported-kind + violation predicates report no violation. State reachability and seeds cannot turn an + unsupported declaration into a proved safety property. +3. **Reject unsupported assertions in the general IOA parser** — Rejected because parsing + and verifier capability are distinct. Runtime consumers may understand a typed form + before every verification backend can prove it; the verification boundary must make + that distinction explicit. +4. **Add a new invariant expression parser in `temper-verify`** — Rejected because it + would duplicate `ParsedAssert` and invite semantic drift. +5. **Rewrite application assertions into model-supported approximations** — Rejected + because it would weaken working safety claims and hide the data-model gap. + +## Rollback Policy + +The diagnostic representation can be revised additively, but unsupported safety +assertions must not return to warning-only behavior. If rollout exposes a required +assertion form, add a typed parser/verification encoding or an explicitly governed +runtime-only contract; do not restore false-success compatibility. diff --git a/docs/proofs/unsupported-safety-invariant-coverage.md b/docs/proofs/unsupported-safety-invariant-coverage.md new file mode 100644 index 000000000..507ae20a4 --- /dev/null +++ b/docs/proofs/unsupported-safety-invariant-coverage.md @@ -0,0 +1,78 @@ +# Safety Invariant Capability Coverage + +This proof records the verification capability boundary introduced by ADR-0190. +Every `[[invariant]]` assertion is translated once into the shared +`InvariantKind` IR. An assertion is model-proved, explicitly attached to typed +runtime enforcement contract version 2, or becomes `Unverifiable`. +`Unverifiable` is a hard `TVE001` error and no cascade backend runs. + +## Checked-in corpus replay + +Replay date: 2026-07-14. + +The automated corpus test recursively reads every `.ioa.toml` file under +`crates/`, `os-apps/`, and `reference-apps/`. For every declaration it verifies +one of two outcomes: + +1. the assertion has a typed model-proved or runtime-enforced `InvariantKind`; or +2. the cascade fails before L0 with `TVE001`, the invariant name and assertion, + and an exact half-open source span whose bytes equal the assertion text. + +Command: + +```console +cargo test -p temper-verify --test production_invariant_corpus -- --nocapture +``` + +Recorded result: + +```text +production invariant corpus: 110 specs, 120 declarations, +120 supported, 0 rejected, 53 distinct forms +test result: ok. 1 passed; 0 failed +``` + +## Supported proof forms + +| Form | Shared IR guarantee | +| --- | --- | +| Declared boolean / `!boolean` | Required truth value is checked by every state evaluator. | +| `is_true boolean` | Normalized to the same declared-boolean IR before verification. | +| `true` | Normalized to an empty conjunction, the typed identity for unconditional truth. | +| `string != ''` | Enforced atomically on tentative state before persistence and replay publication. | +| `counter_a OP counter_b` | Enforced atomically on tentative counters before persistence and replay publication. | +| `counter > N`, `>= N`, `< N`, `<= N`, `== N` | Literal comparison is represented directly and checked by SMT, Stateright, simulation, and property tests. | +| `no_further_transitions` | Trigger states have no enabled transition. | +| `never(State)` | The forbidden state is unreachable. | +| `&&`, `||`, parentheses | Typed child expressions are composed recursively; one unsupported child rejects the whole declaration. | + +The current corpus exercises declared booleans, every listed literal counter +operator, `no_further_transitions`, and compound `&&`/`||` expressions. + +## Rejected production forms + +None. All 53 checked-in assertion forms are either model-proved or attached to +runtime enforcement contract version 2. Unknown syntax remains a `TVE001` +capability error before any backend, cache, or trust decision. + +The parser also recognizes `ordering(Before, After)`, but the shared verifier +IR rejects it until event-history semantics are represented by every backend. + +Rejection is independent of reachability, simulation seed count, property-test +case count, and fail-fast configuration. Direct SMT, Stateright, simulation, +property, and composite entry points also reject the `Unverifiable` IR variant, +so bypassing the cascade cannot turn an omitted safety claim into proof. + +## Runtime-enforced claims + +The corpus contains non-empty string and counter-to-counter assertions. They +compile into the closed `RuntimeAssert` enum and contract version 2. Production +and deterministic simulation share the evaluator; live actions are checked on +tentative post-transition state before persistence/publication with atomic +rollback, checked counter overflow is rejected, blob-backed oversized strings +retain their logical non-empty meaning, and replay/hydration reject invalid durable +state. Caller payloads are type-checked before internal blob-envelope semantics apply. +A hot swap cannot change an existing entity type's runtime contract until +an explicit migration validates its durable state. No arbitrary string expression +can enter this classification. Absence of either a model encoding or this explicit +contract remains a deployment-blocking error. diff --git a/reference-apps/crucible/tests/crucible_agents_validation.rs b/reference-apps/crucible/tests/crucible_agents_validation.rs index 3136764cc..bf61b7246 100644 --- a/reference-apps/crucible/tests/crucible_agents_validation.rs +++ b/reference-apps/crucible/tests/crucible_agents_validation.rs @@ -100,6 +100,8 @@ fn build_crucible_state() -> ServerState { summary: "OK".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-04-11T00:00:00Z".to_string(), }), ); diff --git a/reference-apps/crucible/tests/crucible_chat_integration.rs b/reference-apps/crucible/tests/crucible_chat_integration.rs index 3d5863194..47bbb9592 100644 --- a/reference-apps/crucible/tests/crucible_chat_integration.rs +++ b/reference-apps/crucible/tests/crucible_chat_integration.rs @@ -121,6 +121,8 @@ fn build_crucible_state() -> ServerState { summary: "OK".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-04-11T00:00:00Z".to_string(), }), ); diff --git a/reference-apps/crucible/tests/crucible_sessions_validation.rs b/reference-apps/crucible/tests/crucible_sessions_validation.rs index f9c3ef287..3724d365b 100644 --- a/reference-apps/crucible/tests/crucible_sessions_validation.rs +++ b/reference-apps/crucible/tests/crucible_sessions_validation.rs @@ -115,6 +115,8 @@ fn build_crucible_state() -> ServerState { summary: "OK".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-04-11T00:00:00Z".to_string(), }), ); diff --git a/reference-apps/crucible/tests/crucible_validation.rs b/reference-apps/crucible/tests/crucible_validation.rs index aef6886dd..fc579f64a 100644 --- a/reference-apps/crucible/tests/crucible_validation.rs +++ b/reference-apps/crucible/tests/crucible_validation.rs @@ -96,6 +96,8 @@ fn build_crucible_state() -> ServerState { summary: "OK".to_string(), details: None, }], + warnings: Vec::new(), + errors: Vec::new(), verified_at: "2026-04-11T00:00:00Z".to_string(), }), ); diff --git a/test-fixtures/specs/gmail_oauth.ioa.toml b/test-fixtures/specs/gmail_oauth.ioa.toml index 77a026d19..19d5ebf7a 100644 --- a/test-fixtures/specs/gmail_oauth.ioa.toml +++ b/test-fixtures/specs/gmail_oauth.ioa.toml @@ -84,7 +84,7 @@ entity_param = "state" [webhook.extract] code = "query.code" -[[invariant]] +[[liveness]] name = "DisconnectedIsRecoverable" -when = ["Disconnected"] -assert = "ordering(Disconnected, Expired)" +from = ["Disconnected", "Expired"] +has_actions = true diff --git a/test-fixtures/specs/order.ioa.toml b/test-fixtures/specs/order.ioa.toml index 7ee0f5d1e..a9b74798b 100644 --- a/test-fixtures/specs/order.ioa.toml +++ b/test-fixtures/specs/order.ioa.toml @@ -30,6 +30,11 @@ name = "has_address" type = "bool" initial = "false" +[[state]] +name = "payment_captured" +type = "bool" +initial = "false" + # --- Actions: Order Lifecycle --- [[action]] @@ -60,6 +65,7 @@ name = "ConfirmOrder" kind = "internal" from = ["Submitted"] to = "Confirmed" +effect = "set payment_captured true" hint = "System action: confirms after payment authorization." [[action]]