From ca76ee16bb7d36696391a3354035fc86a6505f93 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:23:07 -0700 Subject: [PATCH 1/2] fix(spec): reject undeliverable IOA webhooks (ARN-227) Outbound IOA webhooks were accepted and expanded into integrations but never executed by production dispatch (wasm/adapter only). That certified dead work. Reject both action-trigger kind=webhook and legacy integration type=webhook (including omitted type default) at validation with a stable durable-delivery error. Keep webhook syntax deserializable for the precise error; do not synthesize webhook integrations or custom effects. Clarify docs and platform integration engine scope. ADR-0176 records the rejection boundary and the future acceptance gate for a journaled delivery runtime. --- crates/temper-mcp/src/protocol.rs | 6 +- crates/temper-platform/src/integration/mod.rs | 14 +- .../src/integration/registry.rs | 3 +- .../temper-platform/src/integration/types.rs | 2 +- .../tests/integration_engine.rs | 63 +++--- crates/temper-server/src/registry/mod.rs | 4 +- .../temper-server/src/registry/relations.rs | 12 +- crates/temper-spec/src/automaton/parser.rs | 107 +++------ .../src/automaton/parser_integrations_test.rs | 79 +++++-- .../src/automaton/parser_triggers_test.rs | 88 ++++---- .../src/automaton/translate_test.rs | 3 +- .../src/automaton/trigger_graph.rs | 29 +-- crates/temper-spec/src/automaton/types.rs | 53 +++-- docs/AGENT_GUIDE.md | 84 +++---- docs/PAPER.md | 47 ++-- docs/adrs/0046-unified-action-triggers.md | 16 +- .../0078-inline-action-trigger-adapters.md | 3 +- .../0176-reject-undeliverable-ioa-webhooks.md | 211 ++++++++++++++++++ docs/internal/GAP_TRACKER.md | 2 +- reference-apps/ecommerce/integration.toml | 5 +- reference-apps/oncall/integration.toml | 5 +- skills/temper-agent/SKILL.md | 20 +- 22 files changed, 545 insertions(+), 311 deletions(-) create mode 100644 docs/adrs/0176-reject-undeliverable-ioa-webhooks.md diff --git a/crates/temper-mcp/src/protocol.rs b/crates/temper-mcp/src/protocol.rs index bae7ffcfd..57aa9edd1 100644 --- a/crates/temper-mcp/src/protocol.rs +++ b/crates/temper-mcp/src/protocol.rs @@ -229,9 +229,9 @@ OBSERVABILITY:\n\ \x20 await temper.get_evolution_records(tenant, record_type?) -> O-P-A-D-I records\n\ \x20 await temper.check_sentinel(tenant) -> trigger evolution engine\n\ \n\ -TRIGGERS (ADR-0046): actions declare [[action.triggers]] inline with kind = \"entity\" | \"wasm\" | \"webhook\".\n\ -For HTTP, use kind = \"wasm\" with module = \"http_fetch\", url, and method config keys.\n\ -The webhook kind is parse-only today; use wasm + http_fetch instead.\n\ +TRIGGERS (ADR-0046/ADR-0176): actions declare [[action.triggers]] inline with kind = \"entity\" | \"wasm\" | \"adapter\".\n\ +For non-durable HTTP work, kind = \"wasm\" with module = \"http_fetch\" is available under its documented post-commit contract; put url/method/body under [action.triggers.config]. It is not a durable webhook substitute.\n\ +Both kind = \"webhook\" and legacy [[integration]] type = \"webhook\" are rejected until durable delivery exists.\n\ \n\ COMPILE_WASM: Use compile_wasm(tenant, module_name, rust_source) to compile Rust into WASM.\n\ Source should use `temper_wasm_sdk::prelude::*` and the `temper_module!` macro.\n\ diff --git a/crates/temper-platform/src/integration/mod.rs b/crates/temper-platform/src/integration/mod.rs index 74bac51b0..68230b566 100644 --- a/crates/temper-platform/src/integration/mod.rs +++ b/crates/temper-platform/src/integration/mod.rs @@ -1,11 +1,11 @@ -//! Integration engine: outbox-pattern event-driven integrations. +//! Directly configured webhook integration engine. //! -//! Integrations are declared in IOA specs via `[[integration]]` sections and -//! dispatched asynchronously after state transitions. The state machine remains -//! pure and deterministically verifiable — external calls happen out-of-band. -//! -//! Permanently failed deliveries are routed to a dead-letter queue for -//! later inspection or manual replay. +//! Callers construct an [`IntegrationRegistry`] from [`IntegrationConfig`] values +//! and explicitly submit [`IntegrationEvent`] values. Production entity actors do +//! not populate this registry from IOA declarations or feed transitions into it. +//! The queue and dead-letter queue are in memory, not a durable outbox; outbound +//! IOA webhooks are therefore rejected until journaled delivery is available. +//! Retry and dead-letter behavior remains available to direct API callers. pub mod dead_letter; pub mod engine; diff --git a/crates/temper-platform/src/integration/registry.rs b/crates/temper-platform/src/integration/registry.rs index 097c9a13a..9f00120e7 100644 --- a/crates/temper-platform/src/integration/registry.rs +++ b/crates/temper-platform/src/integration/registry.rs @@ -6,7 +6,8 @@ use super::types::IntegrationConfig; /// Maps trigger event names to integration configurations. /// -/// Built once from the tenant's specs at registration time. +/// Built explicitly by a caller from directly supplied runtime configuration. +/// Production entity registration does not populate it from IOA specs. #[derive(Debug, Clone, Default)] pub struct IntegrationRegistry { /// Maps event name to list of integrations triggered by that event. diff --git a/crates/temper-platform/src/integration/types.rs b/crates/temper-platform/src/integration/types.rs index 790be587a..3876f461a 100644 --- a/crates/temper-platform/src/integration/types.rs +++ b/crates/temper-platform/src/integration/types.rs @@ -10,7 +10,7 @@ use serde_json::Value; /// Configuration for a single integration endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IntegrationConfig { - /// Integration name (matches the IOA `[[integration]]` name). + /// Caller-defined integration name used for diagnostics and dead letters. pub name: String, /// The event that triggers this integration. pub trigger: String, diff --git a/crates/temper-platform/tests/integration_engine.rs b/crates/temper-platform/tests/integration_engine.rs index a4bcaa0ae..dfcd98b7e 100644 --- a/crates/temper-platform/tests/integration_engine.rs +++ b/crates/temper-platform/tests/integration_engine.rs @@ -1,10 +1,10 @@ //! Integration engine tests. //! //! Covers the full integration pipeline: -//! - IOA specs with `[[integration]]` sections parse correctly -//! - IntegrationRegistry lookups match parsed spec integrations +//! - supported IOA `[[integration]]` sections still verify +//! - IntegrationRegistry lookups match directly supplied engine configuration //! - WebhookDispatcher delivers events to a live mock server (wiremock) -//! - Verification cascade still passes for specs with integrations +//! - Verification cascade still passes for specs with supported integrations //! - IntegrationEngine background task dispatches via channel use std::collections::BTreeMap; @@ -60,45 +60,46 @@ to = "Shipped" [[integration]] name = "notify_fulfillment" trigger = "SubmitOrder" -type = "webhook" +type = "wasm" +module = "notify_fulfillment" [[integration]] name = "charge_payment" trigger = "ConfirmOrder" -type = "webhook" +type = "wasm" +module = "charge_payment" [[integration]] name = "notify_shipping" trigger = "ShipOrder" -type = "webhook" +type = "wasm" +module = "notify_shipping" "#; // ----------------------------------------------------------------------- -// Parser → Registry integration +// Direct engine configuration // ----------------------------------------------------------------------- #[test] -fn parsed_integrations_populate_registry() { - let automaton = parse_automaton(ORDER_IOA_WITH_INTEGRATIONS).expect("should parse"); - assert_eq!(automaton.integrations.len(), 3); - - // Build IntegrationConfigs from parsed Integration structs (production would - // read deployment config; here we synthesize configs from the spec). - let configs: Vec = automaton - .integrations - .iter() - .map(|ig| IntegrationConfig { - name: ig.name.clone(), - trigger: ig.trigger.clone(), - webhook: WebhookConfig { - url: format!("https://example.com/{}", ig.name), - method: "POST".to_string(), - headers: BTreeMap::new(), - timeout_ms: 5000, - }, - retry: RetryPolicy::default(), - }) - .collect(); +fn direct_configs_populate_registry() { + let configs: Vec = [ + ("notify_fulfillment", "SubmitOrder"), + ("charge_payment", "ConfirmOrder"), + ("notify_shipping", "ShipOrder"), + ] + .into_iter() + .map(|(name, trigger)| IntegrationConfig { + name: name.to_string(), + trigger: trigger.to_string(), + webhook: WebhookConfig { + url: format!("https://example.com/{name}"), + method: "POST".to_string(), + headers: BTreeMap::new(), + timeout_ms: 5000, + }, + retry: RetryPolicy::default(), + }) + .collect(); let registry = IntegrationRegistry::from_configs(configs); assert_eq!(registry.len(), 3); @@ -123,12 +124,12 @@ fn parsed_integrations_populate_registry() { } #[test] -fn spec_with_integrations_in_entity_spec() { +fn supported_integrations_remain_in_entity_spec_metadata() { let automaton = parse_automaton(ORDER_IOA_WITH_INTEGRATIONS).expect("should parse"); // Ensure the automaton itself carries integration metadata assert_eq!(automaton.integrations[0].name, "notify_fulfillment"); assert_eq!(automaton.integrations[0].trigger, "SubmitOrder"); - assert_eq!(automaton.integrations[0].integration_type, "webhook"); + assert_eq!(automaton.integrations[0].integration_type, "wasm"); assert_eq!(automaton.integrations[1].name, "charge_payment"); assert_eq!(automaton.integrations[2].name, "notify_shipping"); } @@ -352,7 +353,7 @@ async fn engine_process_event_skips_unmatched() { } // ----------------------------------------------------------------------- -// Verification cascade: specs with [[integration]] still pass +// Verification cascade: specs with supported [[integration]] still pass // ----------------------------------------------------------------------- #[test] diff --git a/crates/temper-server/src/registry/mod.rs b/crates/temper-server/src/registry/mod.rs index 8319e7208..d684200e5 100644 --- a/crates/temper-server/src/registry/mod.rs +++ b/crates/temper-server/src/registry/mod.rs @@ -373,8 +373,8 @@ impl SpecRegistry { for (tenant, config) in &self.tenants { let mut rules = config.reactions.clone(); // ADR-0046: synthesize reaction rules from [[action.triggers]] - // entity-kind blocks on every entity's actions. Wasm/Webhook - // kinds are handled by a separate runtime path. + // entity-kind blocks on every entity's actions. WASM/adapter + // kinds are handled by separate runtime paths; webhooks are rejected. for (entity_type, spec) in &config.entities { for action in &spec.automaton.actions { for trigger in &action.triggers { diff --git a/crates/temper-server/src/registry/relations.rs b/crates/temper-server/src/registry/relations.rs index 0ac23f3a4..321579787 100644 --- a/crates/temper-server/src/registry/relations.rs +++ b/crates/temper-server/src/registry/relations.rs @@ -92,10 +92,11 @@ fn nav_target_entity(type_name: &str) -> String { /// Synthesize a `ReactionRule` from an `[[action.triggers]]` entry (ADR-0046). /// -/// Returns `None` for `kind = "wasm"` and `kind = "webhook"` triggers — those -/// are handled by a separate runtime path in a later slice. Returns `Some` -/// for `kind = "entity"` triggers, translating the declaration into the -/// existing reaction machinery, including the declared trigger principal. +/// Returns `None` for non-entity triggers. WASM and adapter triggers use the +/// integration runtime; webhook triggers cannot reach registry synthesis from +/// a validated IOA spec (ADR-0176). Returns `Some` for `kind = "entity"` +/// triggers, translating the declaration into the existing reaction machinery, +/// including the declared trigger principal. /// /// Guard translation: `TriggerGuard` and `ReactionGuard` are structurally /// identical enums living in different crates (spec vs server layer). The @@ -105,8 +106,7 @@ pub(super) fn synthesize_action_trigger_reaction( source_action: &str, trigger: &ActionTrigger, ) -> Option { - // Only entity-kind triggers map to ReactionRules. Wasm / Webhook - // triggers have a different runtime (deferred to a later slice). + // Only entity-kind triggers map to ReactionRules. if trigger.kind != TriggerKind::Entity { return None; } diff --git a/crates/temper-spec/src/automaton/parser.rs b/crates/temper-spec/src/automaton/parser.rs index 9db406227..4fee30fa2 100644 --- a/crates/temper-spec/src/automaton/parser.rs +++ b/crates/temper-spec/src/automaton/parser.rs @@ -10,6 +10,9 @@ use super::toml_parser; use super::types::*; use crate::tlaplus::{Invariant as TlaInvariant, StateMachine, Transition}; +const OUTBOUND_IOA_WEBHOOK_UNSUPPORTED: &str = + "outbound IOA webhooks are unsupported until durable delivery is available"; + /// Errors from parsing an automaton specification. #[derive(Debug, thiserror::Error)] pub enum AutomatonParseError { @@ -72,24 +75,26 @@ pub fn parse_automaton_with_liveness( // ADR-0049: wire each state_timeout's `state` into the target action's // `from` list so the action is actually enabled from that state. wire_state_timeout_from_states(&mut automaton); - // ADR-0046/0078: expand `[[action.triggers]]` external integration blocks + // ADR-0046/0078/0176: expand supported `[[action.triggers]]` external + // integration blocks // into synthesized `[[integration]]` entries + action effects so the - // existing WASM/adapter/webhook runtime picks them up without needing a parallel + // existing WASM/adapter runtime picks them up without needing a parallel // dispatch path. Entity-kind triggers are handled separately by the - // reaction dispatcher. + // reaction dispatcher; webhook triggers are rejected during validation. expand_external_action_triggers(&mut automaton)?; // ADR-0050: enforce (or warn on) liveness coverage. check_liveness_coverage(&automaton, mode)?; Ok(automaton) } -/// ADR-0046/0078: translate external `[[action.triggers]]` declarations into +/// ADR-0046/0078/0176: translate supported external `[[action.triggers]]` +/// declarations into /// the existing `[[integration]]` + `Effect::Trigger` runtime. For each such /// trigger, synthesizes: /// /// 1. A new `Integration` appended to `automaton.integrations` with -/// fields copied from the trigger (module / adapter / url / method / -/// config / on_success / on_failure). +/// fields copied from the trigger (module / adapter / config / +/// on_success / on_failure). /// 2. A `trigger` effect on the source action so the transition table /// emits a `custom_effect` that the runtime's integration dispatcher /// picks up by name. @@ -120,10 +125,7 @@ fn expand_external_action_triggers(automaton: &mut Automaton) -> Result<(), Auto std::collections::BTreeMap::new(); for action in &automaton.actions { for trigger in &action.triggers { - if matches!( - trigger.kind, - TriggerKind::Wasm | TriggerKind::Adapter | TriggerKind::Webhook - ) { + if matches!(trigger.kind, TriggerKind::Wasm | TriggerKind::Adapter) { inline_trigger_owners .entry(trigger.name.clone()) .or_default() @@ -136,12 +138,7 @@ fn expand_external_action_triggers(automaton: &mut Automaton) -> Result<(), Auto let local_inline_trigger_names: std::collections::BTreeSet = action .triggers .iter() - .filter(|trigger| { - matches!( - trigger.kind, - TriggerKind::Wasm | TriggerKind::Adapter | TriggerKind::Webhook - ) - }) + .filter(|trigger| matches!(trigger.kind, TriggerKind::Wasm | TriggerKind::Adapter)) .map(|trigger| trigger.name.clone()) .collect(); @@ -184,10 +181,7 @@ fn expand_external_action_triggers(automaton: &mut Automaton) -> Result<(), Auto }) .collect(); for trigger in &action.triggers { - if !matches!( - trigger.kind, - TriggerKind::Wasm | TriggerKind::Adapter | TriggerKind::Webhook - ) { + if !matches!(trigger.kind, TriggerKind::Wasm | TriggerKind::Adapter) { continue; } let synth_name = synthesized_trigger_name(&action.name, &trigger.name); @@ -237,42 +231,7 @@ fn expand_external_action_triggers(automaton: &mut Automaton) -> Result<(), Auto config, }); } - TriggerKind::Webhook => { - // ADR-0046 known gap: we synthesize the Integration record - // but no runtime dispatcher keys on integration_type == - // "webhook" today (only "wasm" via wasm.rs:200 and - // "adapter" via adapter.rs:96). A spec-declared webhook - // trigger parses and installs but never fires HTTP. Real - // outbound webhook delivery currently runs through - // temper-server's separate WebhookDispatcher + webhooks.toml - // path. A follow-up will add state/dispatch/webhook.rs - // and collapse the two paths. The config-flattening below - // stays so the Integration record is immediately usable - // once that dispatcher lands. - let mut config = trigger.config.clone(); - if let Some(url) = &trigger.url { - config.insert("url".to_string(), url.clone()); - } - if let Some(method) = &trigger.method { - config.insert("method".to_string(), method.clone()); - } - for (k, v) in &trigger.headers { - config.insert(format!("header.{k}"), v.clone()); - } - if let Some(body) = &trigger.body_template { - config.insert("body_template".to_string(), body.clone()); - } - synthesized.push(Integration { - name: synth_name.clone(), - trigger: synth_name.clone(), - integration_type: "webhook".to_string(), - module: None, - on_success: trigger.on_success.clone(), - on_failure: trigger.on_failure.clone(), - llm: false, - config, - }); - } + TriggerKind::Webhook => return Err(unsupported_webhook_trigger(action, trigger)), } } } @@ -559,9 +518,16 @@ fn validate(automaton: &Automaton) -> Result<(), AutomatonParseError> { } } - // 3. Validate WASM integrations. + // 3. Validate supported integrations. ADR-0176 rejects legacy outbound + // webhooks before verification, JIT construction, or runtime dispatch. let action_names: Vec<&str> = automaton.actions.iter().map(|a| a.name.as_str()).collect(); for ig in &automaton.integrations { + if ig.integration_type == "webhook" { + return Err(AutomatonParseError::Validation(format!( + "integration '{}': {OUTBOUND_IOA_WEBHOOK_UNSUPPORTED}", + ig.name + ))); + } if ig.integration_type == "wasm" { if ig.module.is_none() { return Err(AutomatonParseError::Validation(format!( @@ -705,11 +671,12 @@ fn validate_vector_decls(automaton: &Automaton) -> Result<(), AutomatonParseErro /// /// Checks performed (parse-time, per-entity — cross-entity checks like /// target-action existence happen at registry load time): -/// - Kind-specific required fields present. +/// - Webhook declarations are rejected until durable delivery exists. +/// - Kind-specific required fields present for accepted kinds. /// - `to_state` (if set) is a declared state. /// - Trigger guard nesting depth ≤ `MAX_TRIGGER_GUARD_DEPTH`. /// - `params` and `params_from` keys must not collide. -/// - For `Wasm`/`Adapter`/`Webhook` kinds: `on_success`/`on_failure` reference +/// - For `Wasm`/`Adapter` kinds: `on_success`/`on_failure` reference /// actions declared on the same source entity. /// - Trigger names within a single action must be unique. fn validate_action_triggers( @@ -775,18 +742,7 @@ fn validate_action_triggers( } } TriggerKind::Webhook => { - if trigger.url.as_deref().is_none_or(str::is_empty) { - return Err(AutomatonParseError::Validation(format!( - "trigger '{}' on action '{}' is kind=\"webhook\" but missing 'url'", - trigger.name, action.name - ))); - } - if trigger.method.as_deref().is_none_or(str::is_empty) { - return Err(AutomatonParseError::Validation(format!( - "trigger '{}' on action '{}' is kind=\"webhook\" but missing 'method'", - trigger.name, action.name - ))); - } + return Err(unsupported_webhook_trigger(action, trigger)); } } @@ -801,7 +757,7 @@ fn validate_action_triggers( } // on_success / on_failure must reference actions declared on this - // source entity (they dispatch on the source after module/HTTP). + // source entity (they dispatch on the source after external work). if let Some(ref cb) = trigger.on_success && !action_names.contains(&cb.as_str()) { @@ -848,6 +804,13 @@ fn validate_action_triggers( Ok(()) } +fn unsupported_webhook_trigger(action: &Action, trigger: &ActionTrigger) -> AutomatonParseError { + AutomatonParseError::Validation(format!( + "trigger '{}' on action '{}': {OUTBOUND_IOA_WEBHOOK_UNSUPPORTED}", + trigger.name, action.name + )) +} + #[cfg(test)] #[path = "parser_test.rs"] mod tests; diff --git a/crates/temper-spec/src/automaton/parser_integrations_test.rs b/crates/temper-spec/src/automaton/parser_integrations_test.rs index b417291f5..12091fcf5 100644 --- a/crates/temper-spec/src/automaton/parser_integrations_test.rs +++ b/crates/temper-spec/src/automaton/parser_integrations_test.rs @@ -2,7 +2,7 @@ use super::super::*; use super::ORDER_IOA; #[test] -fn test_integration_section_parsed() { +fn supported_integration_sections_parse() { let toml = r#" [automaton] name = "Order" @@ -15,38 +15,80 @@ from = ["Draft"] to = "Submitted" [[integration]] -name = "notify_fulfillment" +name = "run_fulfillment" trigger = "SubmitOrder" -type = "webhook" +type = "wasm" +module = "fulfillment" [[integration]] -name = "charge_payment" +name = "record_payment" trigger = "ConfirmOrder" -type = "webhook" +type = "adapter" +adapter = "payment-ledger" "#; let automaton = parse_automaton(toml).expect("should parse"); assert_eq!(automaton.integrations.len(), 2); - assert_eq!(automaton.integrations[0].name, "notify_fulfillment"); + assert_eq!(automaton.integrations[0].name, "run_fulfillment"); assert_eq!(automaton.integrations[0].trigger, "SubmitOrder"); - assert_eq!(automaton.integrations[0].integration_type, "webhook"); - assert_eq!(automaton.integrations[1].name, "charge_payment"); + assert_eq!(automaton.integrations[0].integration_type, "wasm"); + assert_eq!( + automaton.integrations[0].module.as_deref(), + Some("fulfillment") + ); + assert_eq!(automaton.integrations[1].name, "record_payment"); + assert_eq!(automaton.integrations[1].integration_type, "adapter"); + assert_eq!( + automaton.integrations[1] + .config + .get("adapter") + .map(String::as_str), + Some("payment-ledger") + ); } #[test] -fn test_integration_default_type() { +fn legacy_webhook_integration_is_rejected_until_delivery_is_durable() { let toml = r#" [automaton] name = "Order" -states = ["Draft", "Submitted"] +states = ["Draft"] initial = "Draft" [[integration]] -name = "notify" +name = "notify_fulfillment" trigger = "SubmitOrder" +type = "webhook" "#; - let automaton = parse_automaton(toml).expect("should parse"); - assert_eq!(automaton.integrations.len(), 1); - assert_eq!(automaton.integrations[0].integration_type, "webhook"); + + let err = parse_automaton(toml) + .expect_err("a legacy outbound webhook without durable runtime support must be rejected"); + assert!( + err.to_string() + .contains("outbound IOA webhooks are unsupported until durable delivery is available"), + "expected the durable-delivery validation error, got: {err}" + ); +} + +#[test] +fn defaulted_legacy_webhook_integration_is_also_rejected() { + let toml = r#" +[automaton] +name = "Order" +states = ["Draft"] +initial = "Draft" + +[[integration]] +name = "notify_fulfillment" +trigger = "SubmitOrder" +"#; + + let err = parse_automaton(toml) + .expect_err("an omitted legacy integration type defaults to webhook and must be rejected"); + assert!( + err.to_string() + .contains("outbound IOA webhooks are unsupported until durable delivery is available"), + "expected the durable-delivery validation error, got: {err}" + ); } #[test] @@ -67,12 +109,7 @@ initial = "Submitted" name = "ChargePayment" from = ["Submitted"] to = "ChargePending" -effect = "trigger charge_payment" - -[[integration]] -name = "charge_payment" -trigger = "charge_payment" -type = "webhook" +effect = "trigger ChargePaymentEffect" [[action]] name = "ChargeSucceeded" @@ -94,7 +131,7 @@ to = "PaymentFailed" .unwrap(); assert_eq!(charge.effect.len(), 1); match &charge.effect[0] { - Effect::Trigger { name } => assert_eq!(name, "charge_payment"), + Effect::Trigger { name } => assert_eq!(name, "ChargePaymentEffect"), other => panic!("expected Trigger effect, got: {other:?}"), } } diff --git a/crates/temper-spec/src/automaton/parser_triggers_test.rs b/crates/temper-spec/src/automaton/parser_triggers_test.rs index 6d81f66ca..8e1f79753 100644 --- a/crates/temper-spec/src/automaton/parser_triggers_test.rs +++ b/crates/temper-spec/src/automaton/parser_triggers_test.rs @@ -282,7 +282,7 @@ to = "Failed" } #[test] -fn test_action_triggers_webhook_kind() { +fn test_action_trigger_webhook_kind_reports_durable_error() { let spec = r#" [automaton] name = "Order" @@ -310,18 +310,41 @@ name = "NotificationSent" from = ["Confirmed"] to = "Notified" "#; - let automaton = parse_automaton(spec).expect("webhook trigger should parse"); - let confirm = &automaton.actions[0]; - let trigger = &confirm.triggers[0]; - assert_eq!(trigger.kind, TriggerKind::Webhook); - assert_eq!( - trigger.url.as_deref(), - Some("https://hooks.slack.com/services/xxx") + let err = parse_automaton(spec).expect_err("webhook trigger must be rejected"); + let message = err.to_string(); + assert!(message.contains("trigger 'notify_slack' on action 'ConfirmOrder'")); + assert!( + message + .contains("outbound IOA webhooks are unsupported until durable delivery is available") ); - assert_eq!(trigger.method.as_deref(), Some("POST")); - assert_eq!( - trigger.headers.get("Content-Type").map(String::as_str), - Some("application/json") +} + +#[test] +fn webhook_action_trigger_is_rejected_until_delivery_is_durable() { + let spec = r#" +[automaton] +name = "Order" +states = ["Draft", "Confirmed"] +initial = "Draft" + +[[action]] +name = "ConfirmOrder" +from = ["Draft"] +to = "Confirmed" + +[[action.triggers]] +name = "notify_fulfillment" +kind = "webhook" +url = "https://example.com/orders" +method = "POST" +"#; + + let err = parse_automaton(spec) + .expect_err("an outbound webhook without durable runtime support must be rejected"); + assert!( + err.to_string() + .contains("outbound IOA webhooks are unsupported until durable delivery is available"), + "expected the durable-delivery validation error, got: {err}" ); } @@ -525,7 +548,7 @@ kind = "wasm" } #[test] -fn test_webhook_trigger_requires_url_and_method() { +fn test_webhook_rejection_precedes_webhook_field_validation() { let spec_no_url = r#" [automaton] name = "X" @@ -541,11 +564,11 @@ name = "bad" kind = "webhook" method = "POST" "#; + let no_url = parse_automaton(spec_no_url).expect_err("webhook must fail before expansion"); assert!( - parse_automaton(spec_no_url) - .expect_err("missing url must fail") + no_url .to_string() - .contains("url") + .contains("outbound IOA webhooks are unsupported until durable delivery is available") ); let spec_no_method = r#" @@ -563,11 +586,12 @@ name = "bad" kind = "webhook" url = "https://example.com/hook" "#; + let no_method = + parse_automaton(spec_no_method).expect_err("webhook must fail before expansion"); assert!( - parse_automaton(spec_no_method) - .expect_err("missing method must fail") + no_method .to_string() - .contains("method") + .contains("outbound IOA webhooks are unsupported until durable delivery is available") ); } @@ -711,7 +735,7 @@ type = "same_id" assert!(err.to_string().contains("empty")); } -// ─── ADR-0046: wasm/webhook expansion into integrations ───────────────── +// ─── ADR-0046/0078/0176: supported external-trigger expansion ─────────── #[test] fn wasm_trigger_expands_into_integration_and_effect() { @@ -914,7 +938,7 @@ effect = '[{ type = "trigger", name = "GenerateCedarPolicy" }, { type = "trigger } #[test] -fn webhook_trigger_expands_with_url_and_method_in_config() { +fn webhook_trigger_is_rejected_before_expansion() { let spec = r#" [automaton] name = "Order" @@ -941,28 +965,16 @@ name = "NotificationSent" from = ["Confirmed"] to = "Notified" "#; - let automaton = parse_automaton(spec).expect("webhook expansion should parse"); - let ig = automaton - .integrations - .iter() - .find(|i| i.name == "__trigger__:ConfirmOrder:notify_slack") - .expect("synthesized integration"); - assert_eq!(ig.integration_type, "webhook"); - assert_eq!( - ig.config.get("url").map(String::as_str), - Some("https://hooks.slack.com/services/xxx") - ); - assert_eq!(ig.config.get("method").map(String::as_str), Some("POST")); - assert_eq!( - ig.config.get("header.Content-Type").map(String::as_str), - Some("application/json") + let err = parse_automaton(spec).expect_err("webhook must be rejected before expansion"); + assert!( + err.to_string() + .contains("outbound IOA webhooks are unsupported until durable delivery is available") ); - assert_eq!(ig.on_success.as_deref(), Some("NotificationSent")); } #[test] fn entity_kind_trigger_does_not_synthesize_integration() { - // Regression: only Wasm/Webhook expand. Entity-kind triggers go + // Regression: only WASM/adapter triggers expand. Entity-kind triggers go // through the reaction dispatcher and must NOT appear as integrations. let spec = r#" [automaton] diff --git a/crates/temper-spec/src/automaton/translate_test.rs b/crates/temper-spec/src/automaton/translate_test.rs index 011000b39..2a1e9342e 100644 --- a/crates/temper-spec/src/automaton/translate_test.rs +++ b/crates/temper-spec/src/automaton/translate_test.rs @@ -202,7 +202,8 @@ effect = [{ type = "trigger", name = "run_wasm" }, { type = "schedule", action = [[integration]] name = "run_wasm" trigger = "run_wasm" -type = "webhook" +type = "wasm" +module = "test_module" "#; let automaton = parse_automaton(spec).unwrap(); let actions = translate_actions(&automaton); diff --git a/crates/temper-spec/src/automaton/trigger_graph.rs b/crates/temper-spec/src/automaton/trigger_graph.rs index 41f299d71..248ec62cc 100644 --- a/crates/temper-spec/src/automaton/trigger_graph.rs +++ b/crates/temper-spec/src/automaton/trigger_graph.rs @@ -10,11 +10,11 @@ //! from a seed entity via this graph, avoiding unnecessary state-space //! explosion for unrelated entities. //! -//! Only `kind = "entity"` triggers appear here. `kind = "wasm"` and -//! `kind = "webhook"` triggers are opaque to joint verification (their -//! execution is an I/O side-effect, not a state transition on another -//! entity); their `on_success`/`on_failure` dispatches do show up as edges -//! from the source entity to itself if the follow-up is a declared action. +//! Only `kind = "entity"` triggers appear here. `kind = "wasm"` and `kind = +//! "adapter"` triggers are opaque to joint verification because their execution +//! is an I/O side-effect, not a state transition on another entity. `kind = +//! "webhook"` declarations are rejected during IOA validation until durable +//! delivery exists (ADR-0176), so they cannot enter this graph. //! //! Cycles are permitted (a reaction cascade can feed back) and detected; //! the verifier bounds cycle exploration by `MAX_TRIGGER_DEPTH = 8`. @@ -73,9 +73,10 @@ impl TriggerGraph { /// Build a [`TriggerGraph`] from a slice of parsed automatons. /// /// Iterates each entity's actions; for each action's `[[action.triggers]]` - /// block with `kind = "entity"`, emits one edge. `kind = "wasm"` / - /// `kind = "webhook"` triggers are skipped (not part of joint - /// verification of entity state machines). + /// block with `kind = "entity"`, emits one edge. `kind = "wasm"` and + /// `kind = "adapter"` triggers are skipped (not part of joint verification + /// of entity state machines), while webhook triggers cannot reach this API + /// from a validated spec. pub fn from_automatons(automatons: &[&Automaton]) -> Self { let mut graph = TriggerGraph::default(); for aut in automatons { @@ -347,7 +348,7 @@ to = "Working" } #[test] - fn wasm_and_webhook_triggers_skipped() { + fn wasm_triggers_are_skipped() { let spec = r#" [automaton] name = "Order" @@ -365,12 +366,6 @@ kind = "wasm" module = "stripe_charge" on_success = "NotifyUser" -[[action.triggers]] -name = "notify_webhook" -kind = "webhook" -url = "https://example.com" -method = "POST" - [[action]] name = "NotifyUser" from = ["Confirmed"] @@ -378,8 +373,8 @@ to = "Notified" "#; let order = parse_automaton(spec).unwrap(); let graph = TriggerGraph::from_automatons(&[&order]); - // wasm + webhook triggers contribute no edges; Order has no - // entity-kind triggers here, so outgoing should be absent. + // WASM triggers contribute no entity edge; Order has no entity-kind + // triggers here, so outgoing should be absent. assert!(!graph.outgoing.contains_key("Order")); } diff --git a/crates/temper-spec/src/automaton/types.rs b/crates/temper-spec/src/automaton/types.rs index 34cf56049..a5029af77 100644 --- a/crates/temper-spec/src/automaton/types.rs +++ b/crates/temper-spec/src/automaton/types.rs @@ -214,10 +214,10 @@ pub struct Action { pub record_parent_event: bool, /// Outgoing triggers fired post-commit of this action (ADR-0046). /// - /// Each trigger describes one cross-entity dispatch, WASM module - /// invocation, or webhook call. Triggers fire after the source action's - /// transition commits (fire-and-forget — failures do not roll back the - /// source). Kind-specific fields are validated at parse time. + /// Each accepted trigger describes one cross-entity dispatch, WASM module + /// invocation, or native adapter call. Triggers fire after the source + /// action's transition commits (fire-and-forget — failures do not roll back + /// the source). Webhook syntax is recognized but rejected by ADR-0176. #[serde(default, rename = "triggers")] pub triggers: Vec, /// Composite-action Cedar gate declaration (ADR-0040). @@ -417,16 +417,20 @@ pub struct Liveness { /// An integration declaration (external system trigger). /// -/// Integrations declare that a state machine event should trigger an external -/// action (e.g., a webhook call or WASM module invocation). They are metadata -/// only — they do not affect state transitions or verification. +/// Integrations declare that a state machine event should trigger external or +/// registered custom work. WASM and adapter are built-in runtime kinds; other +/// registered kinds remain available to custom handlers. Legacy webhook values +/// remain deserializable for an actionable ADR-0176 validation error, but +/// accepted automatons cannot contain them. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Integration { /// Integration name (e.g., "notify_fulfillment", "charge_payment"). pub name: String, /// The event that triggers this integration (action name or trigger name). pub trigger: String, - /// Integration type: "webhook" or "wasm". + /// Integration type. `"wasm"` and `"adapter"` are built in, and registered + /// custom kinds may consume their own metadata. The historical `"webhook"` + /// value is rejected until durable delivery exists (ADR-0176). #[serde(rename = "type", default = "default_webhook")] pub integration_type: String, /// WASM module name (required when `type = "wasm"`). @@ -615,8 +619,8 @@ pub enum TriggerKind { /// Native platform adapter execution. Optionally dispatches `on_success` /// / `on_failure` actions on the source entity afterwards. Adapter, - /// Outbound HTTP webhook. Optionally dispatches `on_success` / `on_failure` - /// actions on the source entity afterwards. + /// Outbound HTTP webhook syntax. Recognized for a precise validation error, + /// but rejected until durable delivery exists (ADR-0176). Webhook, } @@ -631,8 +635,8 @@ pub enum TriggerLiveness { #[default] BestEffort, /// Required. The composite verifier emits a `Property::eventually` that - /// the target action (entity kind) or on_success action (wasm/webhook - /// kinds) fires following the source action. Assumes weakly-fair dispatch. + /// the target action (entity kind) or `on_success` action (WASM/adapter + /// kind) fires following the source action. Assumes weakly-fair dispatch. Required, } @@ -753,17 +757,18 @@ impl TriggerGuard { /// An outgoing trigger declared inline on an `Action` (ADR-0046). /// /// Unifies cross-entity dispatch (former `reactions.toml`), WASM execution -/// (former `[[integration]] type = "wasm"`), native adapters (former -/// `[[integration]] type = "adapter"`), and outbound webhooks (former -/// `[[integration]] type = "webhook"`) under a single `kind`-discriminated -/// schema. +/// (former `[[integration]] type = "wasm"`), and native adapters (former +/// `[[integration]] type = "adapter"`) under a single `kind`-discriminated +/// schema. The outbound-webhook shape remains deserializable so validation can +/// report the durable-delivery requirement, but it is not currently accepted +/// (ADR-0176). /// /// Fields are a superset across kinds; parse-time validation enforces /// presence per `kind`: /// - `Entity`: requires `target_entity` + `target_action`. /// - `Wasm`: requires `module`. /// - `Adapter`: requires `adapter` or `adapter_type`. -/// - `Webhook`: requires `url` + `method`. +/// - `Webhook`: rejected until the durable delivery contract in ADR-0176 exists. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ActionTrigger { /// Human-readable name for logging and debugging. @@ -851,19 +856,19 @@ pub struct ActionTrigger { #[serde(default)] pub adapter_type: Option, - // ─── Webhook-kind fields ──────────────────────────────────────────── - /// Outbound HTTP URL (required for `Webhook` kind). + // ─── Historical webhook-kind fields ───────────────────────────────── + /// Historical outbound HTTP URL syntax retained so ADR-0176 can return a + /// precise durability error for webhook declarations. #[serde(default)] pub url: Option, - /// HTTP method (required for `Webhook` kind — typically POST/PUT/PATCH). + /// Historical HTTP method syntax retained for the ADR-0176 error path. #[serde(default)] pub method: Option, - /// HTTP headers. Values may contain `{secret:key}` templates resolved - /// from tenant-scoped secret storage. + /// Historical HTTP headers retained for the ADR-0176 error path. #[serde(default)] pub headers: BTreeMap, - /// Template for the HTTP request body. `${field}` placeholders are - /// resolved from the source entity's post-action fields. + /// Historical HTTP request-body template retained for the ADR-0176 error + /// path. #[serde(default)] pub body_template: Option, } diff --git a/docs/AGENT_GUIDE.md b/docs/AGENT_GUIDE.md index 055ef3cd3..ab0ca271f 100644 --- a/docs/AGENT_GUIDE.md +++ b/docs/AGENT_GUIDE.md @@ -14,7 +14,7 @@ This document is the primary reference for LLM agents building applications with 6. [Running the Server](#6-running-the-server) 7. [Authorization (Cedar ABAC)](#7-authorization) 8. [Observability — Telemetry as Views](#8-observability--telemetry-as-views) -9. [Integration Engine (External System Webhooks)](#9-integration-engine) +9. [Post-Commit Integrations](#9-post-commit-integrations) 10. [Evolution Engine (How the System Improves)](#10-evolution-engine) 11. [Trajectory Intelligence (How You Optimize Agents)](#11-trajectory-intelligence) 12. [JIT Optimization (Hot-Swap Without Redeploy)](#12-jit-optimization) @@ -347,7 +347,7 @@ with a full `EvalContext` containing counters and booleans: 1. Find matching rule by action name 2. Check `from_states` guard (is current status valid for this action?) 3. Check additional guards (`CounterMin`, `BoolTrue`, compound `And`) -4. If guards pass: apply effects (`SetState`, `IncrementCounter`, `SetBool`, `EmitEvent`, `Custom`), record event. `EmitEvent` feeds the Integration Engine for external webhooks (see [Section 9](#9-integration-engine)). +4. If guards pass: apply effects (`SetState`, `IncrementCounter`, `SetBool`, `EmitEvent`, `Custom`) and record the event. `EmitEvent` is surfaced as a runtime effect; outbound IOA webhooks are rejected until durable delivery exists (see [Section 9](#9-post-commit-integrations)). 5. If guards fail: return 409 Conflict with error message **Critical**: `TransitionTable::from_ioa_source(ioa_toml)` is the sole production constructor. The TLA+ code path has been fully removed. @@ -579,64 +579,54 @@ Evolution records reference these as portable SQL. Swapping providers doesn't br --- -## 9. Integration Engine +## 9. Post-Commit Integrations -Two mechanisms fire work after an action commits: +Supported action-owned post-commit work has four forms: -- **Cross-entity reactions** — in-system choreography (another entity's action). Declarative TOML, no code. Fire-and-forget, bounded cascade, deterministic under `SimReactionSystem`. Use reactions when both source and target are Temper entities. See [`docs/reactions.md`](reactions.md) for the full reference and [ADR-0045](adrs/0045-reactions-first-class-app-primitive.md) for the design. -- **WASM integrations** — out-of-system work (external HTTP, LLM calls, third-party APIs). Described below. Use integrations when you need computation, I/O beyond the Temper cluster, or explicit retry / timeout semantics. +- **Entity triggers** dispatch another Temper entity action through the bounded + reaction machinery. +- **WASM triggers/integrations** execute a registered module through the + governed WASM host. +- **Adapter triggers/integrations** invoke a registered native adapter. +- **Registered custom integrations/effects** use an application-owned handler + with an explicit runtime contract. -Integrations follow the **Outbox Pattern**: the state machine stays pure and deterministically verifiable; external calls happen out-of-band. `[[integration]]` declarations in IOA TOML are metadata — they don't affect state transitions or verification. +Operator-configured `webhooks.toml` subscriptions are a separate trajectory +notification surface. They are not IOA action triggers. -### Spec Syntax +### Outbound IOA Webhooks -Declare integrations alongside your automaton: +Outbound webhook declarations are rejected by validation: ```toml -[[integration]] +[[action.triggers]] name = "notify_fulfillment" -trigger = "SubmitOrder" -type = "webhook" -``` - -The `trigger` names an action. When that action fires, the integration engine picks it up asynchronously. - -### Runtime Architecture - -``` -Entity Actor transition - → Effect::EmitEvent("SubmitOrder") - → mpsc channel - → IntegrationEngine (background tokio task) - → IntegrationRegistry.lookup("SubmitOrder") - → WebhookDispatcher.dispatch(config, event) +kind = "webhook" +url = "https://fulfillment.example.com/orders" +method = "POST" ``` -- **`IntegrationRegistry`** maps trigger event names to `IntegrationConfig` entries (built once at tenant registration from specs + deployment config). -- **`WebhookDispatcher`** handles HTTP dispatch with configurable timeout and retry with exponential backoff. -- **`IntegrationEngine`** runs as a background tokio task, receives `IntegrationEvent` messages via an `mpsc` channel, and dispatches to all registered webhooks for each trigger concurrently. +The legacy `[[integration]] type = "webhook"` form is also rejected, including +an omitted `type` (which historically defaulted to webhook). Both return: -### Deployment Configuration - -Webhook URLs are deployment-specific and live outside the IOA spec. See `reference-apps/ecommerce/integration.toml`: - -```toml -[[webhook]] -name = "notify_fulfillment" -url = "https://fulfillment.example.com/orders" -method = "POST" -timeout_ms = 5000 -max_retries = 3 +```text +outbound IOA webhooks are unsupported until durable delivery is available ``` -Each entry specifies the HTTP endpoint, method, timeout, and retry policy. +This prevents verification and installation from certifying work that the +runtime would silently drop. Do not replace the error with a warning or a direct +HTTP background task. ADR-0176 requires a future implementation to atomically +journal delivery intent with the source transition, recover unfinished work on +replay, use a stable delivery ID, apply trigger and egress authorization +separately, and persist terminal outcomes. -### Key Design Decisions +### Standalone Platform Engine -- **Not inline in the state machine.** Integrations are side effects, not transitions. The verification cascade (L0-L3) works on the pure state machine unchanged. -- **At-least-once delivery.** Trigger events originate from the Postgres event journal, so they survive crashes. -- **Retry with exponential backoff.** Configurable per integration via `RetryPolicy`. -- **DST-safe.** Deterministic simulation ignores `EmitEvent` effects — no HTTP calls during testing. +`temper_platform::integration::IntegrationEngine` remains a directly configured +library API with retry and dead-letter behavior. The production entity actor +does not feed it IOA transition events, and its in-memory queue is not an +outbox. Tests and callers construct `IntegrationConfig` directly; parsed IOA +webhook declarations are not an ingestion path. --- @@ -919,7 +909,7 @@ any of these causes silent failures that are hard to diagnose after the fact. - [ ] `model.csdl.xml` entity types match IOA spec names and states - [ ] Cedar policies exist for each entity type in `specs/policies/` - [ ] No warnings from L0 SMT (dead guards, unreachable states) -- [ ] If specs contain `[[integration]]` sections, `integration.toml` exists with webhook URLs and retry config +- [ ] Every `[[integration]]` type has a registered runtime consumer; outbound IOA webhooks are rejected by ADR-0176 **Persistence (events survive restart):** - [ ] `DATABASE_URL` is set and points to a running Postgres instance @@ -981,7 +971,7 @@ any of these causes silent failures that are hard to diagnose after the fact. | Putting guards inline in action params | Guards are separate from action parameters | Use `guard = "items > 0"` for preconditions, `params = [...]` for action inputs | | Deploying without `DATABASE_URL` | Server runs fine but events are lost on restart — silent data loss | Always set `DATABASE_URL`, verify "Postgres connected" in startup log | | Not querying Postgres after first deploy | No way to know if persistence is actually working | Run `SELECT COUNT(*) FROM events` after dispatching actions | -| Putting webhook calls inside state machine guards or effects | Breaks deterministic verification, introduces network into the transition | Use `[[integration]]` declarations — external calls happen out-of-band via the Integration Engine | +| Declaring an outbound IOA webhook | No durable runtime consumes it, so validation rejects it | Use a supported WASM/adapter integration or an operator `webhooks.toml` subscription with its documented contract | --- diff --git a/docs/PAPER.md b/docs/PAPER.md index 090a819c4..01bb14a8c 100644 --- a/docs/PAPER.md +++ b/docs/PAPER.md @@ -247,9 +247,10 @@ The reference Order automaton (`order.ioa.toml`) defines: `Decrement`, `Emit`). - **Safety invariants:** `SubmitRequiresItems`, `ShipRequiresPayment`, `CancelledIsFinal`, `RefundedIsFinal`. -- **Integration declarations:** `[[integration]]` sections declare external - side effects (webhooks, notifications) as metadata, dispatched asynchronously - after transitions (see Section 4.3.1). +- **Integration declarations:** accepted `[[integration]]` sections describe + WASM, native-adapter, or registered custom work dispatched after transitions. + Outbound webhook declarations are rejected until durable delivery exists + (see Section 4.3.1). The TOML serialization is parsed into an `Automaton` struct that feeds both the verification cascade (directly to `TemperModel` for Stateright model @@ -338,22 +339,24 @@ with in-memory stub implementations. Actor mailboxes currently use local `tokio::sync::mpsc` channels; Redis-backed implementations are planned for distributed deployment. -### 4.3.1 Integration Engine and Outbox Pattern +### 4.3.1 Post-Commit Integration Boundary -External integrations are declared as metadata in the IOA specification -(`[[integration]]` sections) and dispatched asynchronously after state transitions -via the `IntegrationEngine`. The state machine itself remains pure and -deterministically verifiable: the verification cascade operates on transition -rules only and ignores integration metadata. This separation is deliberate-- -the outbox pattern ensures that side effects cannot violate state machine -invariants because they execute *after* the transition is persisted, not -during guard evaluation or effect application. +Accepted IOA integration metadata selects the governed WASM runtime, a +registered native adapter, or an application-owned custom handler. These calls +happen after the state transition, so network and process I/O remain outside +deterministic transition evaluation. -The dispatch flow is: `EntityActor` applies a transition, emits -`Effect::EmitEvent`, the event is persisted to the Postgres journal, and the -`IntegrationEngine` asynchronously dispatches to registered webhook endpoints. -Delivery follows at-least-once semantics with configurable exponential backoff -retry, matching the actor runtime's existing supervision backoff strategy. +The current entity journal persists the transition but does not atomically +persist an external-delivery intent or terminal receipt. Consequently this is +not described as an outbox and does not claim at-least-once delivery across a +process failure. The standalone `temper-platform` integration engine has a +directly configured retry/dead-letter API, but production entity transitions do +not feed it. + +ADR-0176 therefore rejects both `[[action.triggers]] kind = "webhook"` and +legacy `[[integration]] type = "webhook"` declarations before verification. +Webhook syntax can be accepted again only with journaled intent, replay and +retry, stable delivery IDs, two-layer authorization, and durable outcomes. ### 4.4 Bounded Execution (TigerStyle) @@ -563,10 +566,12 @@ L3 Property Tests PASSED: 1000 cases, 30 max steps - The guard/effect language restricts what Z3 can reason about: only integer counters and booleans, no arithmetic expressions or set operations. -**Integration engine limitations:** -- Webhook is the only supported transport; gRPC and message queue transports - are planned. -- Delivery is at-least-once; exactly-once semantics require idempotent receivers. +**Post-commit integration limitations:** +- Outbound IOA webhooks are rejected until a journaled delivery runtime exists; + the standalone webhook engine accepts only explicitly constructed runtime + configuration and events. +- The entity journal does not atomically record an external delivery intent or + receipt, so the platform does not claim at-least-once delivery across a crash. **Future work:** composition calculus for multi-entity verification, fairness-aware liveness checker, richer guard language with arithmetic. diff --git a/docs/adrs/0046-unified-action-triggers.md b/docs/adrs/0046-unified-action-triggers.md index 6f7906ad4..919576566 100644 --- a/docs/adrs/0046-unified-action-triggers.md +++ b/docs/adrs/0046-unified-action-triggers.md @@ -5,6 +5,7 @@ - Accepted: 2026-04-24 - Deciders: Temper core maintainers - Supersedes: ADR-0045 (specifically its Sub-Decision 5 — "Keep reactions separate from actions") +- Partially superseded by: ADR-0176 (outbound webhook acceptance and expansion only) - Related: - ADR-0015: Agent OS Cross-Entity Primitives (cross-entity guards at the action layer) - ADR-0045: Reactions as a First-Class App Primitive (superseded by this ADR in its Sub-Decision 5) @@ -72,11 +73,16 @@ One primitive covers all outgoing effects. Deletes `[[integration]]` and `reacti - `kind = "entity"` — cross-entity action dispatch (former reactions, former `[[agent_trigger]]`). **Runtime: fully wired.** - `kind = "wasm"` — WASM module execution (former `[[integration]] type = "wasm"`). **Runtime: fully wired.** -- `kind = "webhook"` — outbound HTTP (former `[[integration]] type = "webhook"`). **Runtime: NOT yet wired — parse + expand only.** See Known Gaps below. +- `kind = "webhook"` — outbound HTTP syntax. **Rejected by ADR-0176 until durable delivery exists.** -Kind-specific fields are validated at parse time: `Entity` requires `target_entity` + `target_action`; `Wasm` requires `module`; `Webhook` requires `url` + `method`. `on_success` / `on_failure` apply to `Wasm` and `Webhook` kinds and name entity actions on the source entity to dispatch after module/HTTP execution. +Kind-specific fields are validated at parse time: `Entity` requires `target_entity` + `target_action`; `Wasm` requires `module`. `on_success` / `on_failure` apply to accepted external trigger kinds and name entity actions on the source entity to dispatch after execution. -**Known gap — `kind = "webhook"` is parse-only.** The expander synthesizes an `Integration { integration_type: "webhook", … }` record and appends a `trigger` effect to the source action, but no runtime dispatcher matches `integration_type == "webhook"`. Only `wasm` (`crates/temper-server/src/state/dispatch/wasm.rs:200`) and `adapter` (`crates/temper-server/src/state/dispatch/adapter.rs:96`) are dispatched today. Declaring `kind = "webhook"` with a real URL in a spec will silently install but never fire HTTP. Outbound webhook delivery today goes through the separate `WebhookDispatcher` + root-level `webhooks.toml` path (`crates/temper-server/src/webhooks/dispatcher.rs`), which keys on `TrajectoryEntry` matches rather than action-trigger dispatch. A follow-up ADR will add `crates/temper-server/src/state/dispatch/webhook.rs` (parallel to `wasm.rs`) to close this gap and collapse the two webhook paths. +**Webhook boundary (superseded by ADR-0176).** The historical runtime follow-up +did not land. Validation now rejects both +`[[action.triggers]] kind = "webhook"` and legacy `[[integration]] type = +"webhook"` declarations until a journaled delivery runtime exists. The +operator-configured `webhooks.toml` trajectory subscription remains a separate +surface and is not an action trigger. **Why not a separate `kind = "agent"`**: "agent" means different things in different apps. The platform's generic `Agent` entity (states Idle → Assigned → Working → Completed) fits the `[[agent_trigger]]` spawn-and-auto-start pattern. But `paw-agent`'s `Agent` entity is a persistent team-member identity (Created → Active → Archived) with no spawn semantics. Katagami and paw-foresight have no "agent" concept at all. A `kind = "agent"` primitive would force every agent-like entity into the platform's Agent shape. Instead, spawning any agent uses `kind = "entity"` targeting whichever agent entity is registered in the tenant, and the "auto-start on Assign" behavior is expressed as a self-trigger on the target agent's own spec (see Sub-Decision 7). This generalizes across the platform's Agent, paw-agent's Agent, and any app-defined agent entity. @@ -122,7 +128,7 @@ New module `crates/temper-verify/src/composite/`. `CompositeTemperModel` impleme Composition scope is determined by the reachability set of the trigger graph rooted at the entity being verified. Entities with no incoming or outgoing triggers verify in isolation (fast path, single-entity model unchanged). -`kind = "wasm"` and `kind = "webhook"` triggers are modeled symbolically during verification — the WASM execution / HTTP call is opaque — but their `on_success` / `on_failure` dispatches participate in joint verification like any other entity-kind trigger. +`kind = "wasm"` triggers are modeled symbolically during verification — the WASM execution is opaque — but their `on_success` / `on_failure` dispatches participate in joint verification like any other entity-kind trigger. Webhook triggers are rejected before verification (ADR-0176). **Why this shape**: Stateright's `Model` trait is pluggable; composition is a wrapper, not a rewrite. The BFS checker (`checker.rs:43-46`) is model-agnostic. Reuse of `TemperModel` as the building block keeps the single-entity fast path intact and scopes the new complexity to `composite/`. Symbolic handling of external I/O keeps verification tractable without pretending to verify opaque module behavior. @@ -162,7 +168,7 @@ Callers that spawn an Agent just create + assign it with `kind = "entity"` + `re ### Sub-Decision 8: Minimal liveness hook -`ActionTrigger` carries `liveness: TriggerLiveness` with variants `None | BestEffort | Required`, defaulting to `BestEffort`. When `Required`, the composite verifier adds a `Property::eventually` assertion that the target action (entity-kind) or on_success action (wasm/webhook-kind) fires following the source action. +`ActionTrigger` carries `liveness: TriggerLiveness` with variants `None | BestEffort | Required`, defaulting to `BestEffort`. When `Required`, the composite verifier adds a `Property::eventually` assertion that the target action (entity-kind) or on_success action (WASM/adapter kind) fires following the source action. Fairness assumptions are weakly fair for the dispatcher (implicit). No window bounds, no expression DSL, no `within_ms` annotations — those are deferred to a future ADR if apps request them. diff --git a/docs/adrs/0078-inline-action-trigger-adapters.md b/docs/adrs/0078-inline-action-trigger-adapters.md index 76ab93a0e..f54290c55 100644 --- a/docs/adrs/0078-inline-action-trigger-adapters.md +++ b/docs/adrs/0078-inline-action-trigger-adapters.md @@ -3,6 +3,7 @@ - Status: Accepted - Date: 2026-05-02 - Deciders: Temper core maintainers +- Partially superseded by: ADR-0176 (webhook expansion statement only) - Related: - ADR-0046: Unified Action Triggers - `crates/temper-spec/src/automaton/parser.rs` @@ -10,7 +11,7 @@ ## Context -ADR-0046 made `[[action.triggers]]` the canonical way to declare action-local outgoing work. The parser currently synthesizes runtime integrations for `kind = "wasm"` and `kind = "webhook"`, while native adapter execution still depends on legacy `[[integration]] type = "adapter"` declarations. +ADR-0046 made `[[action.triggers]]` the canonical way to declare action-local outgoing work. At the time of this decision the parser synthesized runtime integrations for `kind = "wasm"` and `kind = "webhook"`, while native adapter execution still depended on legacy `[[integration]] type = "adapter"` declarations. ADR-0176 later rejected webhook declarations because no durable runtime consumed them; the WASM comparison remains valid here. That gap leaves test and app specs unable to override an inline WASM trigger with a deterministic native adapter without falling back to older integration syntax. In the GEPA autonomous-loop test, the stale override failed to replace the inline proposer trigger, so CI attempted to execute the unregistered `gepa-proposer-agent` WASM module and transitioned the run to `Failed` before `RecordMutation`. diff --git a/docs/adrs/0176-reject-undeliverable-ioa-webhooks.md b/docs/adrs/0176-reject-undeliverable-ioa-webhooks.md new file mode 100644 index 000000000..41216f10d --- /dev/null +++ b/docs/adrs/0176-reject-undeliverable-ioa-webhooks.md @@ -0,0 +1,211 @@ +# ADR-0176: Reject Undeliverable IOA Webhook Integrations + +- Status: Accepted +- Date: 2026-07-14 +- Deciders: Temper core maintainers +- Supersedes: ADR-0046 Sub-Decision 2's webhook acceptance and known-gap + expansion only +- Related: + - ADR-0002: WASM Sandboxed Integration Runtime for Agent-Generated API Calls + - ADR-0007: Governed External API Calls Through the MCP REPL + - ADR-0046: Unified Action Triggers + - ADR-0152: Integration Failure Is Never Silent + - `crates/temper-spec/src/automaton/parser.rs` + - `crates/temper-server/src/state/dispatch/mod.rs` + +## Context + +ADR-0046 made `[[action.triggers]]` the canonical declaration surface for +entity, WASM, adapter, and webhook work. The parser currently accepts +`kind = "webhook"`, adds a custom transition effect, and synthesizes an +`Integration` with `integration_type = "webhook"`. Production post-dispatch +orchestration only executes integrations whose type is `wasm` or `adapter`. +A valid, verified webhook trigger therefore commits its source transition and +is then silently ignored. + +Adding a direct HTTP branch would make the declaration execute, but would not +make it correct. External delivery begins after the source event commits. +Background WASM dispatch is an unjournaled `tokio::spawn`; replay regenerates +custom effects and discards them; and WASM invocation artifacts are recorded +only after execution starts. A process failure after the source commit but +before dispatch therefore loses the external effect permanently. Lowering a +webhook to the built-in `http_fetch` WASM module would inherit that loss window. + +Such lowering would also bypass common `ActionTrigger` semantics. The current +custom-effect expansion is unconditional, so `to_state` and `guard` are not +applied to WASM, adapter, or webhook integrations. The trigger's optional +`principal` is not used for the source-trigger authorization decision; the WASM +host separately authorizes HTTP under its module principal. Treating those two +checks as interchangeable would violate ADR-0046. + +The repository contains two other HTTP paths, neither of which closes this +gap: + +- `temper-platform::integration` is an exported but unwired library subsystem. + Production transitions do not feed it, and its retry/dead-letter state is + in-memory rather than co-committed with entity state. +- `temper-server::webhooks` is an operator-configured trajectory subscriber. + It intentionally operates outside IOA action-trigger semantics and remains a + working, independently scoped capability. + +Temper must not advertise an external effect that it cannot durably execute. +There are two correct end states: implement a canonical durable external-effect +runtime, or reject the declaration until that runtime exists. This ADR chooses +rejection because the existing execution paths cannot provide the required +contract without a new journaled delivery model. + +## Decision + +### 1. Reject every IOA-owned outbound webhook during validation + +Validation returns a stable error for both outbound declaration forms: + +- `[[action.triggers]] kind = "webhook"`; and +- legacy `[[integration]] type = "webhook"`, including the historical omitted + `type` field that deserializes to `webhook`. + +Rejection happens before action-trigger synthesis, transition-table +construction, registry installation, or action execution. No accepted +automaton may contain an `Integration { integration_type = "webhook" }` record, +and no custom trigger effect may be synthesized from a webhook action trigger. + +`TriggerKind::Webhook` and its source fields remain deserializable so users get +an explicit, actionable validation error instead of an unknown-enum or ignored- +field error. The message states that IOA webhooks are unsupported until durable +delivery is available; it does not suggest a warning-only or best-effort mode. + +This is not a compatibility mode. IOA webhook triggers have never executed in +the production runtime, so rejection removes a false promise rather than a +working capability. Existing specs that contain one fail installation and +verification instead of appearing healthy while dropping work. + +A hand-authored `effect = "trigger "` remains valid when it is not paired +with an explicitly declared webhook integration. `trigger` is the generic +custom-effect extension point and can have a registered runtime handler; its +syntax alone does not identify an HTTP effect. This ADR rejects only declarations +that the IOA schema explicitly classifies as outbound webhooks. + +### 2. Keep working HTTP paths scoped to their existing contracts + +The operator-level `webhooks.toml` trajectory subscriber remains supported. It +does not claim to execute IOA action triggers and must not be silently migrated +or removed. + +The unwired `temper-platform::integration` engine API also remains in this +change. Although it is not connected to production IOA execution, callers can +construct its `IntegrationConfig` directly and its retry and dead-letter +behavior is tested. Removing it before there is a canonical runtime into which +that behavior can be migrated would drop working capability. Its tests no +longer use parsed IOA `[[integration]]` declarations as an ingestion path: they +either construct engine configuration directly or assert the IOA declaration +is rejected. The engine's existence does not make webhook IOA declarations +valid; conformance tests enforce that boundary. + +No new direct HTTP dispatcher, WASM lowering, warning path, or background task +is introduced. + +### 3. Define the acceptance gate for a future webhook runtime + +Webhook declarations may be accepted again only when one canonical integration +runtime provides all of the following: + +1. **Journaled intent.** The source transition and a bounded external-effect + intent are committed atomically. The intent has a deterministic delivery ID + derived from stable entity/event coordinates. +2. **Recovery and retry.** Replay reconstructs unfinished intents. A process + failure at every boundary (before send, after send, before receipt commit) + leaves either retryable work or a durable terminal receipt. At-least-once + HTTP delivery uses the stable delivery ID as an idempotency key; exactly-once + delivery is not claimed. +3. **Uniform trigger semantics.** `to_state`, every supported `guard`, declared + or inherited `principal`, and `liveness` have the same meaning for entity, + WASM, adapter, and webhook triggers. Guard-skipped work creates no delivery + intent. +4. **Two authorization decisions.** Cedar first authorizes firing the trigger + under its declared/inherited principal. The governed HTTP host separately + authorizes network egress for the destination. Neither decision substitutes + for the other. +5. **Durable outcomes.** Success, failure, retry exhaustion, callback dispatch, + and compensation are observable and recoverable. Callback actions carry the + stable delivery ID so replay cannot create unrelated callback attempts. +6. **Conformance.** Every accepted trigger kind has verifier/JIT/runtime tests, + including restart, replay, deterministic ordering, authorization denial, + non-2xx response, retry, and terminal failure cases. + +This gate is a compatibility rule for the schema, not a deferred implementation +phase in this change. Until every item is implemented atomically, the parser is +the enforcement boundary. + +## Rollout Plan + +This ships in one pull request: + +1. Add failing behavioral regressions proving both outbound webhook declaration + forms are currently accepted even though no runtime consumes them. +2. Reject webhook action triggers and legacy webhook integrations, then assert + that no later parser/JIT/runtime layer can observe an accepted webhook + integration. +3. Add conformance coverage showing the remaining accepted trigger kinds still + parse and preserve their existing expansion. +4. Update documentation that currently describes webhook triggers as accepted + or parse-only. +5. Exercise verification and installation against a live local server: the + pre-fix build accepts the broken spec; the fixed build rejects it before any + source action can commit. + +## Readiness Gates + +- Webhook action triggers and legacy webhook integrations fail parsing with the + stable unsupported-durable-delivery error. +- No accepted automaton can contain or synthesize a webhook integration, and no + webhook action trigger can synthesize a custom effect. +- Entity, WASM, and adapter trigger parser conformance tests remain green. +- The operator trajectory webhook tests remain green. +- The live local server rejects installation of a webhook-bearing spec and + performs no outbound request. +- Deterministic parser/JIT outputs for accepted trigger kinds are unchanged. +- `cargo fmt --check`, strict Clippy for touched crates, full workspace tests, + reviewer PASS, Greptile, and CI are clean. + +## Consequences + +### Positive + +- Verification and installation can no longer certify a webhook that runtime + will silently drop. +- The fix does not add another HTTP, authorization, retry, or telemetry path. +- The durability and authorization requirements for future support are + explicit and testable. +- Working operator webhooks and the tested platform integration library are not + removed. + +### Negative + +- Specs containing `kind = "webhook"` fail until the journaled integration + runtime exists. +- Users that mistook prior successful installation for webhook support must use + an explicitly supported integration surface. + +### Risks + +- A caller could depend on parser acceptance even though delivery never + occurred. The validation error is intentional: preserving silent loss would + be a worse compatibility contract. +- Documentation or examples outside this repository may still claim webhook + support. Repository search and release notes must make the rejection visible. + +### DST Compliance + +- Validation is a deterministic, pure function over the parsed automaton. +- The change adds no time source, randomness, thread, I/O, or unordered + collection to simulation-visible crates. +- Replay behavior for accepted effects is unchanged; a rejected webhook can no + longer enter the event journal. + +## Non-Goals + +- Implementing a partial or best-effort webhook dispatcher. +- Treating action idempotency as external-delivery idempotency. +- Treating post-execution invocation logging as an outbox. +- Removing or repurposing operator-level trajectory webhooks. +- Removing tested integration-library behavior before it can be migrated. diff --git a/docs/internal/GAP_TRACKER.md b/docs/internal/GAP_TRACKER.md index 0c04716be..7f80261ba 100644 --- a/docs/internal/GAP_TRACKER.md +++ b/docs/internal/GAP_TRACKER.md @@ -27,7 +27,7 @@ | 11 | No append-only collections | temper-jit, temper-server | **RESOLVED** | Full stack: `Guard::ListContains`, `Guard::ListLengthMin`, `Effect::ListAppend`, `Effect::ListRemoveAt` across temper-spec, temper-jit, temper-server, temper-verify. `lists: BTreeMap>` on EntityState. | | 12 | No persistent evolution record storage | temper-evolution | **RESOLVED** | `PostgresRecordStore` in `pg_store.rs` — `evolution_records` table with JSONB payload, indexes on `(record_type, status)` and `(derived_from)`. Full CRUD + `ranked_insights()` + `update_status()`. | | 13 | No real Redis adapter | temper-store-redis | **RESOLVED** | `RedisMailbox` (RPUSH/LPOP/LLEN), `RedisPlacement` (GET/SET/DEL + scan), `RedisCache` (SET with EX + scan) — all via fred v10. | -| 14 | Integration webhook has no retry | temper-platform | **RESOLVED** | Exponential backoff retry in `WebhookDispatcher`. `DeadLetterQueue` trait + `InMemoryDeadLetterQueue` for permanently failed deliveries. Wired via `with_dlq()`. | +| 14 | IOA webhook integration has no durable runtime | temper-spec, temper-platform | **BOUNDED** | ADR-0176 rejects outbound IOA webhook declarations before verification. The standalone platform engine retains directly configured retry/dead-letter behavior, but is not an IOA ingestion path or durable outbox. | | 15 | Liveness properties not verified | temper-verify | **RESOLVED** | `LivenessViolation` type added. `check_liveness_post_simulation()` checks NoDeadlock + ReachesState. Wired into L2 cascade. `check_reaches_state()` cleaned up in stateright_impl. | | 16 | No Cedar policy hot-reload | temper-authz | **RESOLVED** | `RwLock` with atomic swap via `reload_policies()`. Invalid policies preserve existing set. `policy_count()` helper added. | | 17 | No entity state persistence wiring | temper-server, temper-store-postgres | **RESOLVED** | `EntityActorHandler::handle()` persists events after transitions via `event_store.append()`. Actor recovery replays events via `replay_events()`. Full Postgres EventStore implementation with schema/migrations. | diff --git a/reference-apps/ecommerce/integration.toml b/reference-apps/ecommerce/integration.toml index 7b3eb1dc1..9b84649ec 100644 --- a/reference-apps/ecommerce/integration.toml +++ b/reference-apps/ecommerce/integration.toml @@ -1,7 +1,8 @@ # Integration deployment configuration for the e-commerce reference app. # -# Maps IOA [[integration]] declarations to actual webhook endpoints. -# This file is deployment-specific — not part of the spec itself. +# Example direct IntegrationEngine configuration. Production entity transitions +# do not load or feed this file automatically; explicit caller wiring is required. +# This is deployment-specific configuration, not IOA webhook syntax. [[webhook]] name = "notify_fulfillment" diff --git a/reference-apps/oncall/integration.toml b/reference-apps/oncall/integration.toml index ac6b923c7..980f6d57d 100644 --- a/reference-apps/oncall/integration.toml +++ b/reference-apps/oncall/integration.toml @@ -1,7 +1,8 @@ # Integration deployment configuration for the on-call reference app. # -# Maps IOA [[integration]] declarations to actual webhook endpoints. -# This file is deployment-specific — not part of the spec itself. +# Example direct IntegrationEngine configuration. Production entity transitions +# do not load or feed this file automatically; explicit caller wiring is required. +# This is deployment-specific configuration, not IOA webhook syntax. [[webhook]] name = "notify_agent" diff --git a/skills/temper-agent/SKILL.md b/skills/temper-agent/SKILL.md index 6494930f2..a1367af64 100644 --- a/skills/temper-agent/SKILL.md +++ b/skills/temper-agent/SKILL.md @@ -256,6 +256,8 @@ kind = "wasm" module = "http_fetch" on_success = "FetchSucceeded" on_failure = "FetchFailed" + +[action.triggers.config] url = "https://wttr.in/{city}?format=j1" method = "GET" @@ -530,7 +532,7 @@ await temper.install_app("project-management") **CRITICAL: Use `[automaton]` table header (NOT `automaton WeatherQuery` bare text).** Use `initial` (NOT `initial_state`). -> **ADR-0046 (April 2026):** `[[integration]]` and `[[agent_trigger]]` are gone. All outgoing effects of an action — cross-entity dispatch, WASM modules, webhooks — are unified under `[[action.triggers]]` nested directly inside `[[action]]`. The `is_system → Allow` Cedar bypass is also removed: every trigger goes through Cedar with either the inherited principal or an explicit named principal. +> **ADR-0046 / ADR-0176:** `[[agent_trigger]]` is gone. Entity, WASM, and adapter action-owned effects use `[[action.triggers]]` nested directly inside `[[action]]`; registered legacy/custom `[[integration]]` kinds remain supported. Both `kind = "webhook"` and legacy `[[integration]] type = "webhook"` are rejected until durable delivery exists. Entity-trigger actions go through Cedar with either the inherited principal or an explicit named principal. ```toml [automaton] @@ -560,7 +562,7 @@ hint = "Description." # optional # Name an explicit service to elevate (must match a registered AgentType). [[action.triggers]] name = "trigger_name" -kind = "entity" # "entity" | "wasm" | "webhook" +kind = "entity" # "entity" | "wasm" | "adapter" principal = "my-service" # optional elevation target_entity = "OtherEntity" target_action = "DoTargetThing" @@ -613,6 +615,8 @@ kind = "wasm" module = "http_fetch" on_success = "FetchSucceeded" # action on this entity if module returns Ok on_failure = "FetchFailed" # action on this entity on failure + +[action.triggers.config] url = "https://wttr.in/{city}?format=j1" method = "GET" ``` @@ -620,21 +624,21 @@ method = "GET" | Key | Required | Description | |-----|----------|-------------| | `module` | Yes | WASM module name (`http_fetch` is built-in) | -| `url` | http_fetch | URL template (`{param}` substitution from action params) | -| `method` | http_fetch | `GET` / `POST` / `PUT` / `DELETE` | -| `body` | No | Request body template for POST/PUT | +| `config.url` | http_fetch | URL template (`{param}` substitution from action params) | +| `config.method` | http_fetch | `GET` / `POST` / `PUT` / `DELETE` | +| `config.body` | No | Request body template for POST/PUT | | `on_success` | No | Action on the source entity if module returns Ok | | `on_failure` | No | Action on the source entity on failure | Callback actions receive `{"status_code": "200", "body": "..."}` as params. -#### `kind = "webhook"` — outbound HTTP (parse-only today) +#### `kind = "webhook"` — rejected until delivery is durable -Currently parsed and expanded but **no runtime dispatcher matches it**. Until the webhook dispatcher lands, use `kind = "wasm"` with the `http_fetch` module instead. +Do not generate `kind = "webhook"` or legacy `[[integration]] type = "webhook"`. Validation rejects both forms (including an omitted legacy `type`) with `outbound IOA webhooks are unsupported until durable delivery is available`. A direct background HTTP task would preserve the crash-loss window. Use a governed WASM/adapter integration only when its documented post-commit contract is appropriate, or use the separately configured operator `webhooks.toml` trajectory subscription. ### Principal semantics (no more `is_system` bypass) -`principal` is optional. When omitted, the trigger fires under the same `SecurityContext` that invoked the source action — Cedar evaluates the target action with the inherited principal. When present, a synthetic `SecurityContext` is built with `id = "service:"`, `agent_type = ""`, `agentTypeVerified = true`, and `attributes.dispatched_by_trigger = true`. The named service must match a registered `AgentType` in the tenant. +For `kind = "entity"`, `principal` is optional. When omitted, the trigger fires under the same `SecurityContext` that invoked the source action — Cedar evaluates the target action with the inherited principal. When present, a synthetic `SecurityContext` is built with `id = "service:"`, `agent_type = ""`, `agentTypeVerified = true`, and `attributes.dispatched_by_trigger = true`. The named service must match a registered `AgentType` in the tenant. **There is no `is_system → Allow` shortcut.** A trigger with no Cedar permit will be denied regardless of how it was dispatched. If you write a trigger that should run as a privileged service principal, you must (a) declare `principal = ""` on the trigger, and (b) ensure that service's AgentType has Cedar policies permitting the target action. From 1aa65b42424a2f610f4963791012ed43c075287f Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:28:46 -0700 Subject: [PATCH 2/2] chore(specs): remove dead outbound IOA webhook declarations (ARN-227) Drop [[integration]] type=webhook blocks from fixtures so they load under the ADR-0176 rejection boundary. Clarify crucible scheduler comments to WASM. --- .../specs/heartbeat_run.ioa.toml | 10 ---------- .../crucible/specs/crucible_scheduler.ioa.toml | 8 ++++---- .../crucible/specs/session_schedule.ioa.toml | 4 ++-- test-fixtures/specs/subscription.ioa.toml | 14 +------------- 4 files changed, 7 insertions(+), 29 deletions(-) diff --git a/os-apps/agent-orchestration/specs/heartbeat_run.ioa.toml b/os-apps/agent-orchestration/specs/heartbeat_run.ioa.toml index a69b21975..9ea3823af 100644 --- a/os-apps/agent-orchestration/specs/heartbeat_run.ioa.toml +++ b/os-apps/agent-orchestration/specs/heartbeat_run.ioa.toml @@ -193,13 +193,3 @@ type = "adapter" adapter = "{field:adapter_type}" on_success = "RecordResult" on_failure = "Fail" - -[[integration]] -name = "notify_completed" -trigger = "Complete" -type = "webhook" - -[[integration]] -name = "notify_failed" -trigger = "Fail" -type = "webhook" diff --git a/reference-apps/crucible/specs/crucible_scheduler.ioa.toml b/reference-apps/crucible/specs/crucible_scheduler.ioa.toml index c9ef2f584..4fb93912c 100644 --- a/reference-apps/crucible/specs/crucible_scheduler.ioa.toml +++ b/reference-apps/crucible/specs/crucible_scheduler.ioa.toml @@ -25,8 +25,8 @@ hint = "Begin the heartbeat loop. Triggers the first schedule check." effect = [{ type = "trigger", name = "crucible_check_schedules" }] # ADR-0046: inline trigger — migrated from [[integration]] below. -# Post-parse expansion re-synthesizes the equivalent Integration so -# the existing WASM/webhook runtime handles execution unchanged. +# Post-parse expansion re-synthesizes the equivalent WASM integration so +# the governed WASM runtime handles execution unchanged. [[action.triggers]] name = "crucible_check_schedules" kind = "wasm" @@ -56,8 +56,8 @@ hint = "Check cycle finished. Triggers the heartbeat wait." effect = [{ type = "trigger", name = "crucible_schedule_next_check" }] # ADR-0046: inline trigger — migrated from [[integration]] below. -# Post-parse expansion re-synthesizes the equivalent Integration so -# the existing WASM/webhook runtime handles execution unchanged. +# Post-parse expansion re-synthesizes the equivalent WASM integration so +# the governed WASM runtime handles execution unchanged. [[action.triggers]] name = "crucible_schedule_next_check" kind = "wasm" diff --git a/reference-apps/crucible/specs/session_schedule.ioa.toml b/reference-apps/crucible/specs/session_schedule.ioa.toml index 2b67b2d0c..b8b381b7b 100644 --- a/reference-apps/crucible/specs/session_schedule.ioa.toml +++ b/reference-apps/crucible/specs/session_schedule.ioa.toml @@ -44,8 +44,8 @@ effect = [{ type = "trigger", name = "crucible_cron_trigger" }, { type = "increment", var = "run_count" }] # ADR-0046: inline trigger — migrated from [[integration]] below. -# Post-parse expansion re-synthesizes the equivalent Integration so -# the existing WASM/webhook runtime handles execution unchanged. +# Post-parse expansion re-synthesizes the equivalent WASM integration so +# the governed WASM runtime handles execution unchanged. [[action.triggers]] name = "crucible_cron_trigger" kind = "wasm" diff --git a/test-fixtures/specs/subscription.ioa.toml b/test-fixtures/specs/subscription.ioa.toml index cab28a49e..90d088637 100644 --- a/test-fixtures/specs/subscription.ioa.toml +++ b/test-fixtures/specs/subscription.ioa.toml @@ -1,7 +1,7 @@ # Subscription Management Entity — I/O Automaton Specification # # Models a SaaS subscription lifecycle with payment failure handling. -# 5 states, 8 transition actions, 2 integration webhooks. +# 5 states, 8 transition actions. # Tracks payment failure count and auto-renewal preference. [automaton] @@ -99,15 +99,3 @@ assert = "no_further_transitions" name = "PastDueHasFailure" when = ["PastDue", "Suspended", "Expired"] assert = "payment_failures > 0" - -# --- Integrations --- - -[[integration]] -name = "billing_webhook" -trigger = "PaymentFailed" -type = "webhook" - -[[integration]] -name = "dunning_notice" -trigger = "SuspendSubscription" -type = "webhook"