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..a078a7167 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,135 @@ 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" + ); + } + } + + // 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).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()); + } + 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 +327,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 new file mode 100644 index 000000000..1b5368d05 --- /dev/null +++ b/crates/temper-server/tests/webhook_integrations.rs @@ -0,0 +1,323 @@ +//! 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" + ); +} + +/// 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.