Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/temper-mcp/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\
Expand Down
14 changes: 7 additions & 7 deletions crates/temper-platform/src/integration/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
3 changes: 2 additions & 1 deletion crates/temper-platform/src/integration/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion crates/temper-platform/src/integration/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 32 additions & 31 deletions crates/temper-platform/tests/integration_engine.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<IntegrationConfig> = 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<IntegrationConfig> = [
("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);
Expand All @@ -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");
}
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions crates/temper-server/src/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions crates/temper-server/src/registry/relations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -105,8 +106,7 @@ pub(super) fn synthesize_action_trigger_reaction(
source_action: &str,
trigger: &ActionTrigger,
) -> Option<ReactionRule> {
// 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;
}
Expand Down
107 changes: 35 additions & 72 deletions crates/temper-spec/src/automaton/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -136,12 +138,7 @@ fn expand_external_action_triggers(automaton: &mut Automaton) -> Result<(), Auto
let local_inline_trigger_names: std::collections::BTreeSet<String> = 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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)),
}
}
}
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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));
}
}

Expand All @@ -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())
{
Expand Down Expand Up @@ -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;
Loading
Loading