From 1192105975a18609efcbf568bf90f46ff4948bad Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:00:47 -0700 Subject: [PATCH 1/3] test(server): failing test for dead spec-declared webhook integrations (ARN-227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED: an IOA spec can declare [[integration]] blocks with type = "webhook" — the DEFAULT integration type. They parse, pass verification ("metadata only"), and deploy, but the runtime executes only type = "wasm" integrations, and the WebhookDispatcher fires only from the server-level webhooks.toml. A developer's declared webhook is silently dead configuration: accepted everywhere, executed nowhere. The test drives a real dispatch against a spec whose webhook integration targets a local capturing listener and asserts the HTTP request arrives; a negative guard pins that only the declared trigger fires it. Co-Authored-By: Claude Fable 5 --- .../tests/webhook_integrations.rs | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 crates/temper-server/tests/webhook_integrations.rs diff --git a/crates/temper-server/tests/webhook_integrations.rs b/crates/temper-server/tests/webhook_integrations.rs new file mode 100644 index 000000000..a04528471 --- /dev/null +++ b/crates/temper-server/tests/webhook_integrations.rs @@ -0,0 +1,233 @@ +//! ARN-227: spec-declared webhook integrations must actually execute. +//! +//! An IOA spec can declare `[[integration]]` blocks with `type = "webhook"` +//! (the DEFAULT integration type). They parse, verify ("metadata only"), and +//! deploy — but the runtime executed only `type = "wasm"` integrations, and +//! the separate `WebhookDispatcher` fires only from the server-level +//! `webhooks.toml`. A developer's declared webhook was silently dead +//! configuration: accepted everywhere, executed nowhere. + +use std::sync::Arc; +use std::time::Duration; + +use temper_runtime::{ActorSystem, TenantId}; +use temper_server::ServerState; +use temper_server::registry::SpecRegistry; +use temper_server::request_context::AgentContext; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +const ORDER_CSDL: &str = r#" + + + + + + + + + + + + +"#; + +fn order_spec_with_webhook_integration(url: &str) -> String { + format!( + r#" +[automaton] +name = "Order" +states = ["Draft", "Submitted"] +initial = "Draft" + +[[action]] +name = "SubmitOrder" +kind = "input" +from = ["Draft"] +to = "Submitted" +hint = "Submit the order." + +[[integration]] +name = "notify_fulfillment" +trigger = "SubmitOrder" +type = "webhook" +url = "{url}" +"# + ) +} + +/// Bind a local listener that captures the first HTTP request it receives +/// (start-line + headers + body as one string) and returns 200. +async fn capturing_listener() -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let captured: Arc>> = Arc::new(Mutex::new(None)); + let capture = Arc::clone(&captured); + + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + let mut buf = vec![0u8; 16 * 1024]; + let mut collected = Vec::new(); + // Read until headers AND the content-length body are complete + // (bounded by the 500ms idle timeout per read), so a client that + // sends headers and body in separate writes is still captured + // fully. + loop { + match tokio::time::timeout(Duration::from_millis(500), stream.read(&mut buf)).await + { + Ok(Ok(n)) if n > 0 => collected.extend_from_slice(&buf[..n]), + _ => break, + } + let Some(headers_end) = collected + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|p| p + 4) + else { + continue; + }; + let headers = String::from_utf8_lossy(&collected[..headers_end]).to_lowercase(); + let content_length: usize = headers + .lines() + .find_map(|l| l.strip_prefix("content-length:")) + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0); + if collected.len() >= headers_end + content_length { + break; + } + } + let _ = stream + .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n") + .await; + *capture.lock().await = Some(String::from_utf8_lossy(&collected).to_string()); + } + }); + + (format!("http://{addr}/hook"), captured) +} + +/// A spec-declared webhook integration must fire an HTTP request when its +/// trigger action executes successfully. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn declared_webhook_integration_fires_on_its_trigger_action() { + let (url, captured) = capturing_listener().await; + + let mut registry = SpecRegistry::new(); + let csdl = temper_spec::parse_csdl(ORDER_CSDL).expect("csdl parses"); + let spec_toml = order_spec_with_webhook_integration(&url); + registry.register_tenant( + "arn227", + csdl, + ORDER_CSDL.to_string(), + &[("Order", &spec_toml)], + ); + + let system = ActorSystem::new("arn227-test"); + let state = ServerState::from_registry(system, registry); + let tenant = TenantId::new("arn227"); + + let response = state + .dispatch_tenant_action( + &tenant, + "Order", + "ord-1", + "SubmitOrder", + serde_json::json!({}), + &AgentContext::for_service("arn227-test"), + ) + .await + .expect("dispatch succeeds"); + assert!(response.success, "the trigger action itself must succeed"); + + // Bounded wait for the fire-and-forget webhook. + let mut request = None; + for _ in 0..100 { + if let Some(r) = captured.lock().await.clone() { + request = Some(r); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let request = request.expect( + "the declared webhook integration must fire an HTTP request when its \ + trigger action executes — declared-and-verified configuration must \ + not be silently dead", + ); + assert!( + request.starts_with("POST /hook"), + "webhook must POST to the declared url, got: {request}" + ); + assert!( + request.contains("SubmitOrder"), + "webhook payload must identify the triggering action, got: {request}" + ); +} + +/// The integration fires only for ITS trigger: a different action on the same +/// entity type must not call the webhook. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn declared_webhook_integration_does_not_fire_for_other_actions() { + let (url, captured) = capturing_listener().await; + + let spec_toml = format!( + r#" +[automaton] +name = "Order" +states = ["Draft", "Submitted"] +initial = "Draft" + +[[action]] +name = "SubmitOrder" +kind = "input" +from = ["Draft"] +to = "Submitted" +hint = "Submit the order." + +[[action]] +name = "Touch" +kind = "input" +from = ["Draft"] +to = "Draft" +hint = "No-op touch." + +[[integration]] +name = "notify_fulfillment" +trigger = "SubmitOrder" +type = "webhook" +url = "{url}" +"# + ); + + let mut registry = SpecRegistry::new(); + let csdl = temper_spec::parse_csdl(ORDER_CSDL).expect("csdl parses"); + registry.register_tenant( + "arn227", + csdl, + ORDER_CSDL.to_string(), + &[("Order", &spec_toml)], + ); + + let system = ActorSystem::new("arn227-neg-test"); + let state = ServerState::from_registry(system, registry); + let tenant = TenantId::new("arn227"); + + let response = state + .dispatch_tenant_action( + &tenant, + "Order", + "ord-2", + "Touch", + serde_json::json!({}), + &AgentContext::for_service("arn227-test"), + ) + .await + .expect("dispatch succeeds"); + assert!(response.success); + + tokio::time::sleep(Duration::from_millis(500)).await; + assert!( + captured.lock().await.is_none(), + "an integration must fire only for its declared trigger action" + ); +} From 47b52a69ba9b65f4e778572a9fd9b63c3ef6efd6 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:48:01 -0700 Subject: [PATCH 2/3] fix(server): execute spec-declared webhook integrations (ARN-227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GREEN: type = "webhook" integrations — the DEFAULT integration type, including the records the parser synthesizes from [[action.triggers]] webhook blocks (ADR-0046) — now execute after their trigger action commits, from the same post-dispatch hook that fires webhooks.toml webhooks. An integration fires when its trigger matches the action name or any custom effect the transition produced (a superset of the wasm path's semantics). The config contract matches what the parser writes: url (required), method (default POST), header.{Name} keys as HTTP headers with {secret:key} values resolved through the same resolve_secret_templates pass the wasm and adapter paths use (a header still carrying an unresolved secret template is dropped, never leaked), and body_template/payload_template as the request body with trajectory variables and entity-field placeholders expanded. Unknown config keys are ignored and logged, never sent. Fire-and-forget on a shared client held by ServerState: webhook latency or failure never blocks or fails the action. The review round's Critical caught my first cut inventing its own contract (every unknown key as a literal header) — which would have activated the ADR-0046 synthesized records with silently wrong semantics: header.X-Api-Key as a literal header name, secrets unresolved, body_template leaking as a header. A conformance test now pins the synth contract end to end. ADR-0164 records the decision (execute rather than reject; the issue offers both branches), the contract, at-most-once delivery as the chosen semantics, and the L0 residuals (filed as ARN-264). Co-Authored-By: Claude Fable 5 --- .../src/state/dispatch/effects.rs | 103 ++++++++++--- crates/temper-server/src/state/mod.rs | 6 + .../temper-server/src/webhooks/dispatcher.rs | 145 ++++++++++++++++++ .../tests/webhook_integrations.rs | 90 +++++++++++ crates/temper-spec/src/automaton/parser.rs | 17 +- .../0164-spec-webhook-integrations-execute.md | 80 ++++++++++ 6 files changed, 408 insertions(+), 33 deletions(-) create mode 100644 docs/adrs/0164-spec-webhook-integrations-execute.md diff --git a/crates/temper-server/src/state/dispatch/effects.rs b/crates/temper-server/src/state/dispatch/effects.rs index 49a612641..e18d72261 100644 --- a/crates/temper-server/src/state/dispatch/effects.rs +++ b/crates/temper-server/src/state/dispatch/effects.rs @@ -426,32 +426,91 @@ impl crate::state::ServerState { .send(crate::state::ObserveRefreshHint::Agents); } + /// Build the trajectory entry webhook dispatch renders templates from. + fn trajectory_entry_for_webhooks( + &self, + ctx: &PostDispatchContext<'_>, + response: &EntityResponse, + ) -> TrajectoryEntry { + TrajectoryEntry { + timestamp: sim_now().to_rfc3339(), + tenant: ctx.tenant.to_string(), + entity_type: ctx.entity_type.to_string(), + entity_id: ctx.entity_id.to_string(), + action: ctx.action.to_string(), + success: response.success, + from_status: response.state.events.back().map(|e| e.from_status.clone()), + to_status: Some(response.state.status.clone()), + error: response.error.clone(), + agent_id: ctx.agent_ctx.agent_id.clone(), + session_id: ctx.agent_ctx.session_id.clone(), + authz_denied: None, + denied_resource: None, + denied_module: None, + source: Some(TrajectorySource::Entity), + spec_governed: None, + agent_type: ctx.agent_ctx.agent_type.clone(), + request_body: None, + intent: ctx.agent_ctx.intent.clone(), + matched_policy_ids: None, + } + } + /// Fire webhooks for the trajectory entry (non-blocking). pub(crate) fn fire_webhooks(&self, ctx: &PostDispatchContext<'_>, response: &EntityResponse) { + // ARN-227: spec-declared webhook integrations fire here, matching the + // action name or any custom effect it produced — a superset of the + // wasm path's trigger semantics (wasm fires only on custom effects). + // They execute regardless of whether a webhooks.toml dispatcher is + // configured. + let spec_webhooks: Vec = { + // Poison-tolerant read: this is a fire-and-forget side path, and + // a poisoned registry lock must not panic the dispatch response. + let registry = self + .registry + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + registry + .get_spec(ctx.tenant, ctx.entity_type) + .map(|spec| { + spec.integrations + .iter() + .filter(|ig| { + ig.integration_type == "webhook" + && (ig.trigger == ctx.action + || response.custom_effects.contains(&ig.trigger)) + }) + .cloned() + .collect() + }) + .unwrap_or_default() + }; + if !spec_webhooks.is_empty() { + let entry = self.trajectory_entry_for_webhooks(ctx, response); + for integration in &spec_webhooks { + // The same secret-template resolution the wasm and adapter + // integration paths apply (ADR-0046 `{secret:key}` headers). + let resolved_config = match self.secrets_vault.as_ref() { + Some(vault) => crate::secrets::template::resolve_secret_templates( + &integration.config, + vault, + &ctx.tenant.to_string(), + ), + None => integration.config.clone(), + }; + crate::webhooks::WebhookDispatcher::dispatch_spec_integration( + &self.spec_webhook_client, + integration, + &resolved_config, + &entry, + &response.state.fields, + ); + } + } + if let Some(ref dispatcher) = self.webhook_dispatcher { let dispatcher = Arc::clone(dispatcher); - let entry = TrajectoryEntry { - timestamp: sim_now().to_rfc3339(), - tenant: ctx.tenant.to_string(), - entity_type: ctx.entity_type.to_string(), - entity_id: ctx.entity_id.to_string(), - action: ctx.action.to_string(), - success: response.success, - from_status: response.state.events.back().map(|e| e.from_status.clone()), - to_status: Some(response.state.status.clone()), - error: response.error.clone(), - agent_id: ctx.agent_ctx.agent_id.clone(), - session_id: ctx.agent_ctx.session_id.clone(), - authz_denied: None, - denied_resource: None, - denied_module: None, - source: Some(TrajectorySource::Entity), - spec_governed: None, - agent_type: ctx.agent_ctx.agent_type.clone(), - request_body: None, - intent: ctx.agent_ctx.intent.clone(), - matched_policy_ids: None, - }; + let entry = self.trajectory_entry_for_webhooks(ctx, response); let from_status = entry.from_status.as_deref().unwrap_or("unknown"); let to_status = entry.to_status.as_deref().unwrap_or("unknown"); let outcome = if entry.success { "succeeded" } else { "failed" }; diff --git a/crates/temper-server/src/state/mod.rs b/crates/temper-server/src/state/mod.rs index e9cd9796d..ae07ef462 100644 --- a/crates/temper-server/src/state/mod.rs +++ b/crates/temper-server/src/state/mod.rs @@ -427,6 +427,10 @@ pub struct ServerState { pub reaction_dispatcher: Arc>>>, /// Optional webhook dispatcher for external system notifications. pub webhook_dispatcher: Option>, + /// Shared HTTP client for spec-declared webhook integrations (ARN-227). + /// Always present — spec integrations fire regardless of whether a + /// `webhooks.toml` dispatcher is configured. + pub(crate) spec_webhook_client: reqwest::Client, /// Native adapter integration registry (`type = "adapter"` dispatch path). pub adapter_registry: Arc, /// WASM module registry: maps (tenant, module_name) → sha256_hash. @@ -692,6 +696,7 @@ impl ServerState { pg_record_store: None, reaction_dispatcher: Arc::new(RwLock::new(None)), webhook_dispatcher: None, + spec_webhook_client: reqwest::Client::new(), adapter_registry: Arc::new(AdapterRegistry::with_builtins()), wasm_module_registry: Arc::new(RwLock::new(WasmModuleRegistry::new())), wasm_engine: Arc::new(WasmEngine::default()), @@ -940,6 +945,7 @@ impl ServerState { pg_record_store: None, reaction_dispatcher: Arc::new(RwLock::new(None)), webhook_dispatcher: None, + spec_webhook_client: reqwest::Client::new(), adapter_registry: Arc::new(AdapterRegistry::with_builtins()), wasm_module_registry: Arc::new(RwLock::new(WasmModuleRegistry::new())), wasm_engine: Arc::new(WasmEngine::default()), diff --git a/crates/temper-server/src/webhooks/dispatcher.rs b/crates/temper-server/src/webhooks/dispatcher.rs index 434dd9d35..6abb61069 100644 --- a/crates/temper-server/src/webhooks/dispatcher.rs +++ b/crates/temper-server/src/webhooks/dispatcher.rs @@ -6,6 +6,8 @@ use std::collections::BTreeMap; +use temper_spec::automaton::Integration; + use crate::state::TrajectoryEntry; /// Configuration for a single webhook endpoint. @@ -154,6 +156,127 @@ impl WebhookDispatcher { } } + /// Fire a spec-declared webhook integration (ARN-227). + /// + /// `[[integration]]` blocks with `type = "webhook"` — including the + /// records the parser synthesizes from `[[action.triggers]]` webhook + /// blocks (ADR-0046) — are executed here after their trigger action + /// commits. The config contract matches what the parser writes: + /// - `url` (required; a missing url is a warn — the L0 gap of accepting + /// it is noted in ADR-0164), + /// - `method` (default POST), + /// - `body_template` / `payload_template`: the request body, with + /// `${...}` trajectory variables and `${field}` entity-field + /// placeholders expanded (default body: a JSON object of the + /// transition), + /// - `header.{Name}` keys: sent as HTTP header `Name`. The caller + /// resolves `{secret:key}` templates in config values BEFORE calling + /// (the same `resolve_secret_templates` pass the wasm/adapter paths + /// use); a header value still carrying an unresolved `{secret:` + /// template is dropped with a warning rather than leaked to the + /// remote host. + /// - Any other config key is ignored (debug-logged), never sent. + /// + /// Fire-and-forget with the same never-block-the-action semantics as + /// `webhooks.toml` dispatch; fires only for successful actions. + pub fn dispatch_spec_integration( + client: &reqwest::Client, + integration: &Integration, + resolved_config: &BTreeMap, + entry: &TrajectoryEntry, + entity_fields: &serde_json::Value, + ) { + if !entry.success { + return; + } + let Some(url) = resolved_config.get("url").cloned() else { + tracing::warn!( + integration = %integration.name, + trigger = %integration.trigger, + "webhook integration has no url; nothing to call" + ); + return; + }; + + let method = resolved_config + .get("method") + .map(|m| m.to_ascii_uppercase()) + .unwrap_or_else(|| "POST".to_string()); + let method = + reqwest::Method::from_bytes(method.as_bytes()).unwrap_or(reqwest::Method::POST); + + let template = resolved_config + .get("body_template") + .or_else(|| resolved_config.get("payload_template")); + let payload = match template { + Some(template) => { + let expanded = expand_template(template, entry); + expand_entity_fields(&expanded, entity_fields) + } + None => serde_json::json!({ + "tenant": entry.tenant, + "entity_type": entry.entity_type, + "entity_id": entry.entity_id, + "action": entry.action, + "from_status": entry.from_status, + "to_status": entry.to_status, + "integration": integration.name, + }) + .to_string(), + }; + + let mut headers: Vec<(String, String)> = Vec::new(); + for (key, value) in resolved_config { + if matches!( + key.as_str(), + "url" | "method" | "payload_template" | "body_template" + ) { + continue; + } + if let Some(header_name) = key.strip_prefix("header.") { + if value.contains("{secret:") { + tracing::warn!( + integration = %integration.name, + header = %header_name, + "webhook header value carries an unresolved secret template; header dropped" + ); + continue; + } + headers.push((header_name.to_string(), value.clone())); + } else { + tracing::debug!( + integration = %integration.name, + key = %key, + "unknown webhook integration config key ignored" + ); + } + } + + let client = client.clone(); + let name = integration.name.clone(); + tokio::spawn(async move { + // determinism-ok: fire-and-forget webhook side-effect; no simulation-visible state touched + let mut builder = client + .request(method, &url) + .header("Content-Type", "application/json") + .body(payload); + for (k, v) in &headers { + builder = builder.header(k.as_str(), v.as_str()); + } + match builder.send().await { + Ok(resp) if resp.status().is_success() => { + tracing::debug!(integration = %name, url = %url, status = %resp.status(), "spec webhook integration dispatched"); + } + Ok(resp) => { + tracing::warn!(integration = %name, url = %url, status = %resp.status(), "spec webhook integration returned non-success status"); + } + Err(e) => { + tracing::warn!(integration = %name, url = %url, error = %e, "spec webhook integration dispatch failed"); + } + } + }); + } + /// Returns `true` if the config should fire for the given trajectory entry. fn matches(&self, config: &WebhookConfig, entry: &TrajectoryEntry) -> bool { if config.on_success_only && !entry.success { @@ -196,6 +319,28 @@ fn expand_env_vars(s: &str) -> String { result } +/// Expand `${field}` placeholders from the entity's post-action fields +/// (ADR-0046 `body_template` contract). Unknown placeholders are left +/// verbatim; non-string values render as JSON. +fn expand_entity_fields(template: &str, fields: &serde_json::Value) -> String { + let Some(map) = fields.as_object() else { + return template.to_string(); + }; + let mut result = template.to_string(); + for (key, value) in map { + let placeholder = format!("${{{key}}}"); + if !result.contains(&placeholder) { + continue; + } + let rendered = match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + result = result.replace(&placeholder, &rendered); + } + result +} + /// Expand `${variable}` placeholders using fields from a [`TrajectoryEntry`]. fn expand_template(template: &str, entry: &TrajectoryEntry) -> String { template diff --git a/crates/temper-server/tests/webhook_integrations.rs b/crates/temper-server/tests/webhook_integrations.rs index a04528471..1b5368d05 100644 --- a/crates/temper-server/tests/webhook_integrations.rs +++ b/crates/temper-server/tests/webhook_integrations.rs @@ -231,3 +231,93 @@ url = "{url}" "an integration must fire only for its declared trigger action" ); } + +/// ADR-0046 trigger-synthesized webhook integrations (from `[[action.triggers]]` +/// blocks) carry `header.{Name}` and `body_template` config keys. The +/// dispatcher must honor that contract: `header.X-Api-Key` becomes the HTTP +/// header `X-Api-Key` (not a literal "header.x-api-key"), and `body_template` +/// becomes the request body with `${...}` variables expanded — never a header. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn trigger_synthesized_webhook_honors_header_and_body_contract() { + let (url, captured) = capturing_listener().await; + + let spec_toml = format!( + r#" +[automaton] +name = "Order" +states = ["Draft", "Submitted"] +initial = "Draft" + +[[action]] +name = "SubmitOrder" +kind = "input" +from = ["Draft"] +to = "Submitted" +hint = "Submit the order." + +[[action.triggers]] +name = "notify" +kind = "webhook" +url = "{url}" +method = "POST" +body_template = "order ${{entity_id}} moved to ${{to_status}}" + +[action.triggers.headers] +X-Api-Key = "test-key-123" +"# + ); + + let mut registry = SpecRegistry::new(); + let csdl = temper_spec::parse_csdl(ORDER_CSDL).expect("csdl parses"); + registry.register_tenant( + "arn227", + csdl, + ORDER_CSDL.to_string(), + &[("Order", &spec_toml)], + ); + + let system = ActorSystem::new("arn227-synth-test"); + let state = ServerState::from_registry(system, registry); + let tenant = TenantId::new("arn227"); + + let response = state + .dispatch_tenant_action( + &tenant, + "Order", + "ord-9", + "SubmitOrder", + serde_json::json!({}), + &AgentContext::for_service("arn227-test"), + ) + .await + .expect("dispatch succeeds"); + assert!(response.success); + + let mut request = None; + for _ in 0..100 { + if let Some(r) = captured.lock().await.clone() { + request = Some(r); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + let request = request.expect("the trigger-synthesized webhook must fire"); + + let request_lower = request.to_lowercase(); + assert!( + request_lower.contains("x-api-key: test-key-123"), + "header.X-Api-Key must arrive as the header X-Api-Key, got: {request}" + ); + assert!( + !request_lower.contains("header.x-api-key"), + "the header.-prefixed config key must never be sent literally, got: {request}" + ); + assert!( + !request_lower.contains("body_template:"), + "body_template must never be sent as a header, got: {request}" + ); + assert!( + request.contains("order ord-9 moved to Submitted"), + "body_template must become the expanded request body, got: {request}" + ); +} diff --git a/crates/temper-spec/src/automaton/parser.rs b/crates/temper-spec/src/automaton/parser.rs index 9db406227..34bf573f3 100644 --- a/crates/temper-spec/src/automaton/parser.rs +++ b/crates/temper-spec/src/automaton/parser.rs @@ -238,17 +238,12 @@ fn expand_external_action_triggers(automaton: &mut Automaton) -> Result<(), Auto }); } 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. + // ARN-227: these synthesized records are executed by + // temper-server's spec webhook dispatch (fire_webhooks → + // dispatch_spec_integration), which honors this exact + // config flattening: `url`, `method`, `header.{name}` + // keys (with `{secret:key}` values resolved at dispatch), + // and `body_template`. let mut config = trigger.config.clone(); if let Some(url) = &trigger.url { config.insert("url".to_string(), url.clone()); diff --git a/docs/adrs/0164-spec-webhook-integrations-execute.md b/docs/adrs/0164-spec-webhook-integrations-execute.md new file mode 100644 index 000000000..120ce695a --- /dev/null +++ b/docs/adrs/0164-spec-webhook-integrations-execute.md @@ -0,0 +1,80 @@ +# ADR-0164: Spec-Declared Webhook Integrations Execute + +## Status + +Accepted (2026-07-14) + +(Numbered 0164: 0156–0163 are claimed by concurrently open arena branches.) + +## Context + +An IOA spec can declare `[[integration]]` blocks. `type = "webhook"` is the +DEFAULT integration type — yet the runtime executed only `type = "wasm"` +integrations (`dispatch_wasm_integrations_internal` filters on `"wasm"`), +and the `WebhookDispatcher` fires only from the server-level +`webhooks.toml`. A developer's declared webhook parsed, passed the +verification cascade ("integrations are metadata only"), deployed — and +never did anything (ARN-227). Silently accepted configuration that does +nothing is the same failure class as a silently swallowed error. + +## Decision + +1. **Webhook integrations fire post-dispatch**, from the same hook that + fires `webhooks.toml` webhooks (`fire_webhooks`), for successful actions + only. An integration fires when its `trigger` equals the action name or + any custom effect the transition produced — a superset of the wasm + path's trigger semantics (wasm fires only on custom effects). +2. **Config contract — matches what the parser writes**, including the + records it synthesizes from `[[action.triggers]]` webhook blocks + (ADR-0046): `url` is required (missing url is a runtime warn — see + residuals); `method` defaults to POST; `body_template` (the synth key) + or `payload_template` becomes the request body, with `${...}` + trajectory variables and `${field}` entity-field placeholders expanded + (default body: a JSON object of the transition); `header.{Name}` keys + are sent as HTTP header `Name`, with `{secret:key}` values resolved + through the same `resolve_secret_templates` pass the wasm and adapter + integration paths use — a header whose value still carries an + unresolved secret template is dropped with a warning rather than leaked + to the remote host. Any other config key is ignored (debug-logged), + never sent. +3. **Fire-and-forget**: dispatched on a shared client held by + `ServerState` (present regardless of whether `webhooks.toml` is + configured), spawned off the action path — webhook latency or failure + never blocks or fails the action, exactly like `webhooks.toml` + dispatch. Failures are logged with the integration name and url. + +## Consequences + +- Declared webhook integrations now do what the spec says. The `[[webhook]]` + (inbound receiver) and `webhooks.toml` (server-operator config) paths are + unchanged. +- Delivery is at-most-once with no retries or ordering guarantees — the + same contract `webhooks.toml` webhooks have always had. Systems needing + guaranteed delivery need the outbox pattern; that is an explicit + non-goal here. +- Outbound URLs come from specs, which are developer-approved design-time + artifacts gated by the verification cascade — the same trust level as + `webhooks.toml` (operator-authored). No new trust boundary is crossed. + +## Residuals + +- **L0 does not require `url` on webhook integrations.** A webhook + integration without a url is still accepted at verification and warns at + runtime. The verification-cascade rule ("webhook integrations must carry + a url") is the follow-up — queued for Linear with the other L0 gaps. +- Wasm-only fields (`on_success`/`on_failure`) are ignored on webhook + integrations rather than rejected; same L0 follow-up. (Trigger-synthesized + records can carry them — the fire-and-forget webhook path does not + dispatch follow-up actions; systems needing that use `kind = "wasm"` + triggers.) + +## Alternatives Considered + +- **Rejecting `type = "webhook"` at L0 until supported**: honest but + wrong-way — the type is documented, is the default, and the runtime + machinery (dispatcher, template expansion) already existed for + `webhooks.toml`. Executing them is less code than rejecting them well. +- **Blocking dispatch with on_success/on_failure like wasm integrations**: + changes action latency semantics and couples state transitions to + external availability; `webhooks.toml` fire-and-forget is the + established webhook contract. From 23d77cd99e93a24ec5bbb1d938a5d1c9f53f2af7 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:42:01 -0700 Subject: [PATCH 3/3] fix(server): let a declared header.Content-Type replace the JSON default (ARN-227) Greptile P2: RequestBuilder::header appends rather than replaces, so the unconditional JSON default plus a config-declared header.Content-Type sent two Content-Type headers. The default is now applied only when the integration does not declare its own. Co-Authored-By: Claude Fable 5 --- crates/temper-server/src/webhooks/dispatcher.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/temper-server/src/webhooks/dispatcher.rs b/crates/temper-server/src/webhooks/dispatcher.rs index 6abb61069..a078a7167 100644 --- a/crates/temper-server/src/webhooks/dispatcher.rs +++ b/crates/temper-server/src/webhooks/dispatcher.rs @@ -252,14 +252,22 @@ impl WebhookDispatcher { } } + // Default Content-Type only when the integration did not declare its + // own — RequestBuilder::header APPENDS, so setting it unconditionally + // would send two Content-Type headers when a `header.Content-Type` + // config key exists. + let has_content_type = headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("content-type")); + let client = client.clone(); let name = integration.name.clone(); tokio::spawn(async move { // determinism-ok: fire-and-forget webhook side-effect; no simulation-visible state touched - let mut builder = client - .request(method, &url) - .header("Content-Type", "application/json") - .body(payload); + let mut builder = client.request(method, &url).body(payload); + if !has_content_type { + builder = builder.header("Content-Type", "application/json"); + } for (k, v) in &headers { builder = builder.header(k.as_str(), v.as_str()); }