Skip to main content

harn_vm/channels/
mod.rs

1use std::collections::BTreeMap;
2use std::sync::atomic::AtomicBool;
3use std::sync::{Arc, Mutex, OnceLock};
4
5use futures::StreamExt;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use time::format_description::well_known::Rfc3339;
9
10use crate::event_log::{
11    active_event_log, install_memory_for_current_thread, sanitize_topic_component, AnyEventLog,
12    ConsumerId, EventId, EventLog, LogEvent, Topic,
13};
14use crate::llm::vm_value_to_json;
15use crate::runtime_limits::RuntimeLimits;
16use crate::triggers::event::{ChannelEventPayload, KnownProviderPayload};
17use crate::triggers::{ProviderId, ProviderPayload, SignatureStatus, TenantId, TriggerEvent};
18use crate::value::{VmError, VmStream, VmValue};
19
20const CHANNEL_QUEUE_DEPTH: usize = RuntimeLimits::DEFAULT.default_event_log_queue_depth;
21const CHANNEL_EVENT_KIND: &str = "channel.emit";
22const IDEMPOTENCY_HEADER: &str = "harn.channel.id";
23const NAME_HEADER: &str = "harn.channel.name";
24const SCOPE_HEADER: &str = "harn.channel.scope";
25const SCOPE_ID_HEADER: &str = "harn.channel.scope_id";
26const EMITTED_BY_HEADER: &str = "harn.channel.emitted_by";
27
28/// CH-06 (#1877): topic for `transcript.channel.emit` / `transcript.channel.match`
29/// transcript events. Consumers subscribe to this topic to render channel
30/// activity alongside reminder/suspension lifecycle events.
31pub(crate) const CHANNEL_TRANSCRIPT_TOPIC: &str = "transcript.channel.lifecycle";
32pub(crate) const CHANNEL_EMIT_TRANSCRIPT_KIND: &str = "transcript.channel.emit";
33pub(crate) const CHANNEL_MATCH_TRANSCRIPT_KIND: &str = "transcript.channel.match";
34
35/// CH-07 (#1878): durable audit topic for the replay-determinism receipts
36/// emitted alongside every channel emit + every channel match. Consumers
37/// drive replay/audit tooling off this topic instead of the lossy
38/// `transcript.channel.lifecycle` summary topic — receipts carry the full
39/// payload, signed timestamps, and cached match linkage so the
40/// `replay_oracle` can byte-compare two runs of the same workload.
41pub const CHANNEL_AUDIT_TOPIC: &str = "lifecycle.channel.audit";
42pub(crate) const CHANNEL_EMIT_RECEIPT_KIND: &str = "channel_emit_receipt";
43pub(crate) const CHANNEL_MATCH_RECEIPT_KIND: &str = "channel_match_receipt";
44/// CH-07 (#1878): receipt schema header so downstream tooling can
45/// version-gate parsers (mirrors `harn.pool_submit.v1` from PL-06 / #1891).
46const CHANNEL_EMIT_RECEIPT_SCHEMA: &str = "harn.channel_emit_receipt.v1";
47const CHANNEL_MATCH_RECEIPT_SCHEMA: &str = "harn.channel_match_receipt.v1";
48
49/// CH-11 (#1911): event kinds + schema header for guardrail middleware
50/// audit entries. Block + warn outcomes both write to the same
51/// `lifecycle.channel.audit` topic so security review tooling reads a
52/// single stream and discriminates on `kind`.
53pub(crate) const CHANNEL_GUARDRAIL_BLOCKED_KIND: &str = "channel_guardrail_blocked";
54pub(crate) const CHANNEL_GUARDRAIL_WARNING_KIND: &str = "channel_guardrail_warning";
55const CHANNEL_GUARDRAIL_AUDIT_SCHEMA: &str = "harn.channel_guardrail_audit.v1";
56
57/// CH-06 (#1877): per-event headers stamped on the `TriggerEvent` so the
58/// channel-match dispatcher can link the `ChannelMatch` span back to the
59/// originating `ChannelEmit` span across the async boundary (also through
60/// the aggregation buffer for batched triggers).
61const EMIT_TRACE_ID_HEADER: &str = "harn.channel.emit_trace_id";
62const EMIT_SPAN_ID_HEADER: &str = "harn.channel.emit_span_id";
63
64static SESSION_CHANNEL_LOG: OnceLock<Mutex<Option<Arc<AnyEventLog>>>> = OnceLock::new();
65static SIGNING_SALT: OnceLock<Vec<u8>> = OnceLock::new();
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69enum ChannelScope {
70    Session,
71    Pipeline,
72    Tenant,
73    Org,
74}
75
76impl ChannelScope {
77    fn parse(value: &str) -> Result<Self, ChannelError> {
78        match value.trim() {
79            "session" => Ok(Self::Session),
80            "pipeline" => Ok(Self::Pipeline),
81            "tenant" => Ok(Self::Tenant),
82            "org" => Ok(Self::Org),
83            other => Err(ChannelError::malformed(format!(
84                "HARN-CHN-003 malformed channel scope '{other}'"
85            ))),
86        }
87    }
88
89    fn as_str(self) -> &'static str {
90        match self {
91            Self::Session => "session",
92            Self::Pipeline => "pipeline",
93            Self::Tenant => "tenant",
94            Self::Org => "org",
95        }
96    }
97}
98
99#[derive(Clone, Debug, Default)]
100struct ChannelContext {
101    task_id: Option<String>,
102    root_task_id: Option<String>,
103    scope_id: Option<String>,
104    workflow_id: Option<String>,
105    run_id: Option<String>,
106    worker_id: Option<String>,
107    agent_session_id: Option<String>,
108    root_agent_session_id: Option<String>,
109    tenant_id: Option<String>,
110}
111
112#[derive(Clone, Debug, Default)]
113struct ChannelOptions {
114    scope: Option<ChannelScope>,
115    id: Option<String>,
116    tenant_id: Option<String>,
117    session_id: Option<String>,
118    pipeline_id: Option<String>,
119    from_cursor: Option<EventId>,
120    limit: Option<usize>,
121    ttl_ms: Option<i64>,
122}
123
124#[derive(Clone, Debug)]
125struct ResolvedChannel {
126    scope: ChannelScope,
127    scope_id: String,
128    resolved_name: String,
129    topic: Topic,
130    retention: &'static str,
131}
132
133#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
134pub struct SignedTimestamp {
135    pub at_ms: i64,
136    pub at: String,
137    pub algorithm: String,
138    pub key_id: String,
139    pub signature: String,
140}
141
142#[derive(Clone, Debug, Serialize, Deserialize)]
143struct StoredChannelEvent {
144    id: String,
145    name: String,
146    payload: serde_json::Value,
147    emitted_at: SignedTimestamp,
148    emitted_by: String,
149    scope: String,
150    scope_id: String,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pipeline_id: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    session_id: Option<String>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    tenant_id: Option<String>,
157    retention: String,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    ttl_ms: Option<i64>,
160}
161
162/// CH-07 (#1878): durable replay-determinism receipt for a single
163/// `emit_channel(...)` call. Pairs 1:1 with the `ChannelEmit` span (CH-06
164/// / #1877) and with the durable journal append. Persisted to the
165/// `lifecycle.channel.audit` event-log topic so the `replay_oracle` can
166/// reproduce the entire emit chain across two runs of the same workload.
167///
168/// `payload_hash` lets the oracle detect producer-side drift
169/// (`HARN-REP-CHN-002`) without having to canonicalize the full payload
170/// during comparison; `payload` carries the verbatim value for byte
171/// equality + replay reconstruction.
172#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
173pub struct ChannelEmitReceipt {
174    pub event_id: String,
175    pub name_resolved: String,
176    pub scope: String,
177    pub scope_id: String,
178    pub payload_hash: String,
179    pub payload: serde_json::Value,
180    pub emitted_at: SignedTimestamp,
181    pub emitted_by: String,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub pipeline_id: Option<String>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub session_id: Option<String>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub tenant_id: Option<String>,
188    pub topic: String,
189    pub inserted: bool,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub span_id: Option<u64>,
192}
193
194/// CH-07 (#1878): durable replay-determinism receipt for a single channel
195/// match — one per `(binding, event)` pair the dispatcher fires. For
196/// aggregation/batched triggers (CH-04 / #1875) the receipt carries
197/// `batch.constituent_event_ids` so the replay oracle can verify the
198/// full batch composition matches across runs (`HARN-REP-CHN-003`).
199///
200/// `event_id` doubles as the cached-match key: on replay the dispatcher
201/// looks up the recorded match by `event_id` instead of re-evaluating
202/// the filter spec, preserving "the journal IS the spec" determinism.
203#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
204pub struct ChannelMatchReceipt {
205    pub event_id: String,
206    pub trigger_id: String,
207    pub binding_key: String,
208    pub name_resolved: String,
209    pub scope: String,
210    pub scope_id: String,
211    pub matched_at: SignedTimestamp,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub matched_in_session_id: Option<String>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub batch: Option<ChannelMatchBatchInfo>,
216    pub handler_kind: String,
217    pub handler_result: ChannelMatchResultSummary,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub span_id: Option<u64>,
220}
221
222/// CH-07 (#1878): batched-dispatch summary stamped onto
223/// `ChannelMatchReceipt`. The `constituent_event_ids` list is the full
224/// recorded composition; replay reconstructs the batch from those ids.
225#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
226pub struct ChannelMatchBatchInfo {
227    pub count: usize,
228    pub constituent_event_ids: Vec<String>,
229}
230
231/// CH-07 (#1878): summary of the handler invocation outcome. Mirrors the
232/// shape of the dispatcher's `DispatchOutcome` but kept lightweight so
233/// the receipt stays compact and self-contained — replay tooling never
234/// needs to cross-reference the dispatcher log to interpret a match.
235#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
236pub struct ChannelMatchResultSummary {
237    pub status: String,
238    pub attempt_count: u32,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub error: Option<String>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub dispatch_failed: Option<bool>,
243}
244
245impl ChannelMatchResultSummary {
246    fn from_dispatch(
247        outcome: &Result<crate::triggers::DispatchOutcome, crate::triggers::DispatchError>,
248    ) -> Self {
249        match outcome {
250            Ok(outcome) => Self {
251                status: outcome.status.as_str().to_string(),
252                attempt_count: outcome.attempt_count,
253                error: outcome.error.clone(),
254                dispatch_failed: None,
255            },
256            Err(error) => Self {
257                status: "dispatch_error".to_string(),
258                attempt_count: 0,
259                error: Some(error.to_string()),
260                dispatch_failed: Some(true),
261            },
262        }
263    }
264}
265
266#[derive(Debug)]
267struct ChannelError(String);
268
269impl ChannelError {
270    fn missing_pipeline() -> Self {
271        Self("HARN-CHN-001 missing pipeline context for pipeline-scoped channel".to_string())
272    }
273
274    fn cross_tenant(message: impl Into<String>) -> Self {
275        Self(format!("HARN-CHN-002 {}", message.into()))
276    }
277
278    fn malformed(message: impl Into<String>) -> Self {
279        Self(message.into())
280    }
281
282    fn scope_ambiguous(message: impl Into<String>) -> Self {
283        Self(format!("HARN-CHN-004 {}", message.into()))
284    }
285}
286
287impl From<ChannelError> for VmError {
288    fn from(error: ChannelError) -> Self {
289        VmError::Runtime(error.0)
290    }
291}
292
293pub fn reset_channel_state() {
294    if let Some(slot) = SESSION_CHANNEL_LOG.get() {
295        *slot.lock().expect("channel session log poisoned") = None;
296    }
297    // CH-11 (#1911): clear the per-thread guardrail registry so the
298    // next pipeline starts with a clean slate. Mirrors how
299    // SESSION_CHANNEL_LOG is reset above.
300    crate::channel_guardrails::clear();
301}
302
303pub(crate) async fn emit_channel_from_vm(
304    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
305    args: Vec<VmValue>,
306) -> Result<VmValue, VmError> {
307    let name = required_string(args.first(), "emit_channel", "name")?;
308    let payload = vm_value_to_json(
309        args.get(1)
310            .ok_or_else(|| VmError::TypeError("emit_channel: missing payload".to_string()))?,
311    );
312    let options = parse_options(args.get(2), "emit_channel")?;
313    let context = ChannelContext::current(ctx);
314    let resolved = resolve_channel(&name, &options, &context)?;
315    let event_id = options
316        .id
317        .clone()
318        .unwrap_or_else(|| format!("channel_evt_{}", uuid::Uuid::now_v7()));
319    let emitted_by = emitted_by(&context);
320    let emitted_at = signed_timestamp(&resolved, &event_id, &emitted_by);
321    let occurred_at_ms = emitted_at.at_ms;
322
323    // CH-11 (#1911): channel guardrails middleware. Runs BEFORE the
324    // durable journal append so a blocked payload never gets persisted
325    // (the block itself IS persisted on the lifecycle.channel.audit
326    // topic so the audit trail is durable). Warn verdicts proceed but
327    // record an audit. The aggregate decision is the worst verdict
328    // across every registered guardrail.
329    let guardrail_context = serde_json::json!({
330        "name": name,
331        "name_resolved": resolved.resolved_name,
332        "scope": resolved.scope.as_str(),
333        "scope_id": resolved.scope_id,
334        "event_id": event_id,
335        "emitted_by": emitted_by,
336    });
337    let decision = crate::channel_guardrails::evaluate(
338        ctx,
339        &payload,
340        &guardrail_context,
341        &resolved.resolved_name,
342    )
343    .await?;
344    if matches!(
345        decision.verdict,
346        crate::channel_guardrails::Verdict::Block { .. }
347    ) {
348        return handle_blocked_emit(
349            &name,
350            &resolved,
351            &event_id,
352            &emitted_by,
353            &emitted_at,
354            &payload,
355            &decision,
356        )
357        .await;
358    }
359    record_guardrail_warnings(
360        &resolved,
361        &event_id,
362        &emitted_by,
363        &payload,
364        decision.fired.as_slice(),
365    )
366    .await;
367    let record = StoredChannelEvent {
368        id: event_id.clone(),
369        name: resolved.resolved_name.clone(),
370        payload,
371        emitted_at,
372        emitted_by: emitted_by.clone(),
373        scope: resolved.scope.as_str().to_string(),
374        scope_id: resolved.scope_id.clone(),
375        pipeline_id: context.pipeline_id_for_receipt(&resolved),
376        session_id: context.session_id_for_receipt(&resolved),
377        tenant_id: context.tenant_id_for_receipt(&resolved),
378        retention: resolved.retention.to_string(),
379        ttl_ms: options.ttl_ms,
380    };
381
382    // CH-06 (#1877): open the ChannelEmit span around the durable append +
383    // trigger fan-out. The span captures emit-time metadata (scope, name,
384    // payload summary) and its trace/span id is stashed on the trigger
385    // event headers so the downstream ChannelMatch span can link back.
386    let mut emit_span = ChannelSpanGuard::start(
387        crate::tracing::SpanKind::ChannelEmit,
388        format!("channel.emit {}", resolved.resolved_name),
389        Vec::new(),
390    );
391    emit_span.set_metadata("event_id", serde_json::json!(record.id));
392    emit_span.set_metadata("scope", serde_json::json!(resolved.scope.as_str()));
393    emit_span.set_metadata("scope_id", serde_json::json!(resolved.scope_id));
394    emit_span.set_metadata("name_resolved", serde_json::json!(resolved.resolved_name));
395    emit_span.set_metadata("payload_summary", summarize_payload(&record.payload));
396    let emit_span_id = crate::tracing::current_span_id().unwrap_or(0);
397    let emit_link = emit_span.link();
398
399    let mut headers = BTreeMap::new();
400    headers.insert(IDEMPOTENCY_HEADER.to_string(), event_id.clone());
401    headers.insert(NAME_HEADER.to_string(), resolved.resolved_name.clone());
402    headers.insert(
403        SCOPE_HEADER.to_string(),
404        resolved.scope.as_str().to_string(),
405    );
406    headers.insert(SCOPE_ID_HEADER.to_string(), resolved.scope_id.clone());
407    headers.insert(EMITTED_BY_HEADER.to_string(), emitted_by.clone());
408
409    let log = log_for_scope(resolved.scope);
410    let mut log_event = LogEvent::new(
411        CHANNEL_EVENT_KIND,
412        serde_json::to_value(&record)
413            .map_err(|error| VmError::Runtime(format!("emit_channel: encode event: {error}")))?,
414    )
415    .with_headers(headers);
416    log_event.occurred_at_ms = occurred_at_ms;
417    let outcome = log
418        .append_idempotent_by_header(&resolved.topic, IDEMPOTENCY_HEADER, &event_id, log_event)
419        .await
420        .map_err(channel_log_error)?;
421    let receipt = receipt_value(
422        &resolved.topic,
423        outcome.event_id,
424        &outcome.event,
425        outcome.inserted,
426    )?;
427    // CH-06 (#1877): emit the transcript event whether the append was fresh
428    // or idempotent — both outcomes are first-class observability signals.
429    emit_channel_emit_transcript(&record, &resolved, outcome.inserted, emit_span_id);
430    // CH-07 (#1878): persist the durable emit receipt on the
431    // `lifecycle.channel.audit` topic so the replay oracle has the full
432    // emit chain (payload hash, signed timestamp, scope correlation)
433    // independently of the lossy transcript summary topic.
434    record_channel_emit_receipt(&record, &resolved, outcome.inserted, emit_span_id).await;
435    // CH-02 (#1872): fan out the emit to channel-source triggers. Only fresh
436    // appends fan out; idempotent duplicates short-circuit because the producer
437    // already saw the original delivery.
438    if outcome.inserted {
439        let payload_json = outcome
440            .event
441            .payload
442            .get("payload")
443            .cloned()
444            .unwrap_or(serde_json::Value::Null);
445        let context_for_fanout = ChannelContext::current(ctx);
446        let fanout_payload = ChannelEventPayload {
447            id: event_id.clone(),
448            name: parse_name(&name)
449                .map(|parsed| parsed.name)
450                .unwrap_or_else(|_| resolved.resolved_name.clone()),
451            name_resolved: resolved.resolved_name.clone(),
452            scope: resolved.scope.as_str().to_string(),
453            scope_id: resolved.scope_id.clone(),
454            payload: payload_json,
455            emitted_by: emitted_by.clone(),
456            tenant_id: context_for_fanout.tenant_id_for_receipt(&resolved),
457            session_id: context_for_fanout.session_id_for_receipt(&resolved),
458            pipeline_id: context_for_fanout.pipeline_id_for_receipt(&resolved),
459        };
460        dispatch_channel_emit_to_triggers(ctx, &resolved, fanout_payload, emit_link).await?;
461    }
462    emit_span.end();
463    Ok(crate::stdlib::json_to_vm_value(&receipt))
464}
465
466pub(crate) async fn channel_events_from_vm(
467    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
468    args: Vec<VmValue>,
469) -> Result<VmValue, VmError> {
470    let name = required_string(args.first(), "channel_events", "name")?;
471    let options = parse_options(args.get(1), "channel_events")?;
472    let context = ChannelContext::current(ctx);
473    let resolved = resolve_channel(&name, &options, &context)?;
474    let events = log_for_scope(resolved.scope)
475        .read_range(
476            &resolved.topic,
477            options.from_cursor,
478            options.limit.unwrap_or(usize::MAX),
479        )
480        .await
481        .map_err(channel_log_error)?;
482    let values = events
483        .into_iter()
484        .map(|(event_id, event)| event_value(&resolved.topic, event_id, event))
485        .collect::<Result<Vec<_>, _>>()?;
486    Ok(crate::stdlib::json_to_vm_value(&serde_json::Value::Array(
487        values,
488    )))
489}
490
491pub(crate) async fn channel_subscribe_from_vm(
492    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
493    args: Vec<VmValue>,
494) -> Result<VmValue, VmError> {
495    let name = required_string(args.first(), "channel_subscribe", "name")?;
496    let options = parse_options(args.get(1), "channel_subscribe")?;
497    let context = ChannelContext::current(ctx);
498    let resolved = resolve_channel(&name, &options, &context)?;
499    let topic = resolved.topic.clone();
500    let mut events = log_for_scope(resolved.scope)
501        .subscribe(&topic, options.from_cursor)
502        .await
503        .map_err(channel_log_error)?;
504    let (tx, rx) = tokio::sync::mpsc::channel::<Result<VmValue, VmError>>(1);
505    tokio::task::spawn_local(async move {
506        while let Some(next) = events.next().await {
507            let value = match next {
508                Ok((event_id, event)) => event_value(&topic, event_id, event)
509                    .map(|value| crate::stdlib::json_to_vm_value(&value)),
510                Err(error) => Err(channel_log_error(error)),
511            };
512            if tx.send(value).await.is_err() {
513                return;
514            }
515        }
516    });
517    Ok(VmValue::stream(VmStream {
518        done: Arc::new(AtomicBool::new(false)),
519        receiver: Arc::new(tokio::sync::Mutex::new(rx)),
520        cancel: None,
521    }))
522}
523
524pub(crate) async fn channel_consumer_cursor_from_vm(
525    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
526    args: Vec<VmValue>,
527) -> Result<VmValue, VmError> {
528    let name = required_string(args.first(), "channel_consumer_cursor", "name")?;
529    let consumer = required_consumer_id(args.get(1), "channel_consumer_cursor")?;
530    let options = parse_options(args.get(2), "channel_consumer_cursor")?;
531    let context = ChannelContext::current(ctx);
532    let resolved = resolve_channel(&name, &options, &context)?;
533    let cursor = log_for_scope(resolved.scope)
534        .consumer_cursor(&resolved.topic, &consumer)
535        .await
536        .map_err(channel_log_error)?;
537    match cursor {
538        Some(event_id) => Ok(VmValue::Int(event_id_to_i64(
539            event_id,
540            "channel_consumer_cursor",
541        )?)),
542        None => Ok(VmValue::Nil),
543    }
544}
545
546pub(crate) async fn channel_ack_from_vm(
547    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
548    args: Vec<VmValue>,
549) -> Result<VmValue, VmError> {
550    let name = required_string(args.first(), "channel_ack", "name")?;
551    let consumer = required_consumer_id(args.get(1), "channel_ack")?;
552    let cursor = required_event_id(args.get(2), "channel_ack", "cursor")?;
553    let options = parse_options(args.get(3), "channel_ack")?;
554    let context = ChannelContext::current(ctx);
555    let resolved = resolve_channel(&name, &options, &context)?;
556    log_for_scope(resolved.scope)
557        .ack(&resolved.topic, &consumer, cursor)
558        .await
559        .map_err(channel_log_error)?;
560    Ok(crate::stdlib::json_to_vm_value(&serde_json::json!({
561        "name": name,
562        "name_resolved": resolved.resolved_name,
563        "scope": resolved.scope.as_str(),
564        "scope_id": resolved.scope_id,
565        "topic": resolved.topic.as_str(),
566        "consumer_id": consumer.as_str(),
567        "cursor": cursor,
568    })))
569}
570
571impl ChannelContext {
572    fn current(ctx: Option<&crate::vm::AsyncBuiltinCtx>) -> Self {
573        let mut context = Self::default();
574        if let Some(vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) {
575            context.task_id = Some(vm.runtime_context.task_id.clone());
576            context.root_task_id = Some(vm.runtime_context.root_task_id.clone());
577            context.scope_id = vm.runtime_context.scope_id.clone();
578            if let VmValue::Dict(values) = crate::runtime_context::runtime_context_value(&vm) {
579                context.workflow_id = dict_string(&values, "workflow_id");
580                context.run_id = dict_string(&values, "run_id");
581                context.worker_id = dict_string(&values, "worker_id");
582                context.agent_session_id = dict_string(&values, "agent_session_id");
583                context.root_agent_session_id = dict_string(&values, "root_agent_session_id");
584                context.tenant_id = dict_string(&values, "tenant_id");
585            }
586        }
587        context.agent_session_id = context
588            .agent_session_id
589            .or_else(crate::agent_sessions::current_session_id);
590        context
591    }
592
593    fn session_id(&self, options: &ChannelOptions) -> Result<String, ChannelError> {
594        // CH-03 (#1874): an explicit `options.session_id` that disagrees with the
595        // active session is HARN-CHN-004 ambiguity, not a silent override. The
596        // resolver MUST be deterministic for a given runtime context.
597        if let Some(requested) = options.session_id.as_deref() {
598            if let Some(active) = self.agent_session_id.as_deref() {
599                if active != requested {
600                    return Err(ChannelError::scope_ambiguous(format!(
601                        "session scope ambiguous: options.session_id '{requested}' \
602                         conflicts with active session '{active}'"
603                    )));
604                }
605            }
606        }
607        Ok(options
608            .session_id
609            .clone()
610            .or_else(|| self.agent_session_id.clone())
611            .or_else(|| self.root_agent_session_id.clone())
612            .or_else(|| self.scope_id.clone())
613            .or_else(|| self.root_task_id.clone())
614            .unwrap_or_else(|| "session".to_string()))
615    }
616
617    fn pipeline_id(&self, options: &ChannelOptions) -> Result<String, ChannelError> {
618        // CH-03 (#1874): explicit `options.pipeline_id` that conflicts with the
619        // active workflow/run is HARN-CHN-004. Two pipelines cannot share a
620        // resolved channel without an explicit disambiguation.
621        let active = self.workflow_id.clone().or_else(|| self.run_id.clone());
622        if let (Some(requested), Some(active)) = (options.pipeline_id.as_deref(), active.as_deref())
623        {
624            if requested != active {
625                return Err(ChannelError::scope_ambiguous(format!(
626                    "pipeline scope ambiguous: options.pipeline_id '{requested}' \
627                     conflicts with active pipeline '{active}'"
628                )));
629            }
630        }
631        options
632            .pipeline_id
633            .clone()
634            .or(active)
635            .ok_or_else(ChannelError::missing_pipeline)
636    }
637
638    fn tenant_id(
639        &self,
640        options: &ChannelOptions,
641        requested: Option<&str>,
642    ) -> Result<String, ChannelError> {
643        let current = self.tenant_id.as_deref();
644        let requested = requested
645            .map(ToOwned::to_owned)
646            .or_else(|| options.tenant_id.clone());
647        if let (Some(current), Some(requested)) = (current, requested.as_deref()) {
648            if current != requested {
649                return Err(ChannelError::cross_tenant(format!(
650                    "cross-tenant channel emit requires a grant: current tenant '{current}', requested tenant '{requested}'"
651                )));
652            }
653        }
654        Ok(requested
655            .or_else(|| self.tenant_id.clone())
656            .unwrap_or_else(|| "default".to_string()))
657    }
658
659    fn pipeline_id_for_receipt(&self, resolved: &ResolvedChannel) -> Option<String> {
660        match resolved.scope {
661            ChannelScope::Pipeline => Some(resolved.scope_id.clone()),
662            _ => self.workflow_id.clone().or_else(|| self.run_id.clone()),
663        }
664    }
665
666    fn session_id_for_receipt(&self, resolved: &ResolvedChannel) -> Option<String> {
667        match resolved.scope {
668            ChannelScope::Session => Some(resolved.scope_id.clone()),
669            _ => self
670                .agent_session_id
671                .clone()
672                .or_else(|| self.root_agent_session_id.clone()),
673        }
674    }
675
676    fn tenant_id_for_receipt(&self, resolved: &ResolvedChannel) -> Option<String> {
677        match resolved.scope {
678            ChannelScope::Tenant => Some(resolved.scope_id.clone()),
679            _ => self.tenant_id.clone(),
680        }
681    }
682}
683
684fn resolve_channel(
685    raw_name: &str,
686    options: &ChannelOptions,
687    context: &ChannelContext,
688) -> Result<ResolvedChannel, ChannelError> {
689    let parsed = parse_name(raw_name)?;
690    if let Some(option_scope) = options.scope {
691        if let Some(prefix_scope) = parsed.scope {
692            if prefix_scope != option_scope {
693                return Err(ChannelError::malformed(format!(
694                    "HARN-CHN-003 channel scope prefix '{}' conflicts with options.scope '{}'",
695                    prefix_scope.as_str(),
696                    option_scope.as_str()
697                )));
698            }
699        }
700    }
701
702    let scope = parsed
703        .scope
704        .or(options.scope)
705        .unwrap_or(ChannelScope::Tenant);
706    if scope == ChannelScope::Org {
707        return Err(ChannelError::cross_tenant(
708            "org-scoped channels are disabled until org grants are available",
709        ));
710    }
711
712    validate_channel_name(&parsed.name)?;
713    let scope_id = match scope {
714        ChannelScope::Session => match parsed.scope_id.clone() {
715            Some(id) => id,
716            None => context.session_id(options)?,
717        },
718        ChannelScope::Pipeline => context.pipeline_id(options)?,
719        ChannelScope::Tenant => context.tenant_id(options, parsed.scope_id.as_deref())?,
720        ChannelScope::Org => unreachable!("org scope returned above"),
721    };
722    validate_scope_id(scope, &scope_id)?;
723    let resolved_name = format!("{}:{}:{}", scope.as_str(), scope_id, parsed.name);
724    let topic = Topic::new(format!(
725        "channels.{}.{}.{}",
726        scope.as_str(),
727        sanitize_topic_component(&scope_id),
728        sanitize_topic_component(&parsed.name)
729    ))
730    .map_err(|error| ChannelError::malformed(format!("HARN-CHN-003 {error}")))?;
731    Ok(ResolvedChannel {
732        scope,
733        scope_id,
734        resolved_name,
735        topic,
736        retention: retention_for_scope(scope),
737    })
738}
739
740#[derive(Clone, Debug)]
741struct ParsedName {
742    scope: Option<ChannelScope>,
743    scope_id: Option<String>,
744    name: String,
745}
746
747fn parse_name(raw_name: &str) -> Result<ParsedName, ChannelError> {
748    let raw_name = raw_name.trim();
749    if raw_name.is_empty() {
750        return Err(ChannelError::malformed(
751            "HARN-CHN-003 channel name cannot be empty",
752        ));
753    }
754    let Some((prefix, rest)) = raw_name.split_once(':') else {
755        return Ok(ParsedName {
756            scope: None,
757            scope_id: None,
758            name: raw_name.to_string(),
759        });
760    };
761    let scope = ChannelScope::parse(prefix)?;
762    match scope {
763        ChannelScope::Session | ChannelScope::Pipeline => {
764            if rest.is_empty() || rest.contains(':') {
765                return Err(ChannelError::malformed(format!(
766                    "HARN-CHN-003 malformed {} channel name '{raw_name}'",
767                    scope.as_str()
768                )));
769            }
770            Ok(ParsedName {
771                scope: Some(scope),
772                scope_id: None,
773                name: rest.to_string(),
774            })
775        }
776        ChannelScope::Tenant => {
777            if rest.is_empty() {
778                return Err(ChannelError::malformed(
779                    "HARN-CHN-003 tenant channel name cannot be empty",
780                ));
781            }
782            let (scope_id, name) = match rest.split_once(':') {
783                Some((tenant_id, name)) if !tenant_id.is_empty() && !name.is_empty() => {
784                    (Some(tenant_id.to_string()), name.to_string())
785                }
786                Some(_) => {
787                    return Err(ChannelError::malformed(format!(
788                        "HARN-CHN-003 malformed tenant channel name '{raw_name}'"
789                    )))
790                }
791                None => (None, rest.to_string()),
792            };
793            Ok(ParsedName {
794                scope: Some(scope),
795                scope_id,
796                name,
797            })
798        }
799        ChannelScope::Org => {
800            let Some((org_id, name)) = rest.split_once(':') else {
801                return Err(ChannelError::malformed(format!(
802                    "HARN-CHN-003 org channel names must be org:<org_id>:<name>, got '{raw_name}'"
803                )));
804            };
805            if org_id.is_empty() || name.is_empty() {
806                return Err(ChannelError::malformed(format!(
807                    "HARN-CHN-003 malformed org channel name '{raw_name}'"
808                )));
809            }
810            Ok(ParsedName {
811                scope: Some(scope),
812                scope_id: Some(org_id.to_string()),
813                name: name.to_string(),
814            })
815        }
816    }
817}
818
819fn validate_channel_name(name: &str) -> Result<(), ChannelError> {
820    if name.trim().is_empty()
821        || name.contains(':')
822        || name.chars().any(|ch| ch.is_control() || ch.is_whitespace())
823    {
824        return Err(ChannelError::malformed(format!(
825            "HARN-CHN-003 malformed channel name '{name}'"
826        )));
827    }
828    Ok(())
829}
830
831fn validate_scope_id(scope: ChannelScope, scope_id: &str) -> Result<(), ChannelError> {
832    if scope_id.trim().is_empty()
833        || scope_id
834            .chars()
835            .any(|ch| ch.is_control() || ch.is_whitespace() || ch == ':')
836    {
837        return Err(ChannelError::malformed(format!(
838            "HARN-CHN-003 malformed {} scope id '{scope_id}'",
839            scope.as_str()
840        )));
841    }
842    Ok(())
843}
844
845fn log_for_scope(scope: ChannelScope) -> Arc<AnyEventLog> {
846    match scope {
847        ChannelScope::Session => {
848            let slot = SESSION_CHANNEL_LOG.get_or_init(|| Mutex::new(None));
849            let mut guard = slot.lock().expect("channel session log poisoned");
850            guard
851                .get_or_insert_with(|| {
852                    Arc::new(AnyEventLog::Memory(crate::event_log::MemoryEventLog::new(
853                        CHANNEL_QUEUE_DEPTH,
854                    )))
855                })
856                .clone()
857        }
858        ChannelScope::Pipeline | ChannelScope::Tenant => active_event_log()
859            .unwrap_or_else(|| install_memory_for_current_thread(CHANNEL_QUEUE_DEPTH)),
860        ChannelScope::Org => unreachable!("org-scoped channel log is disabled"),
861    }
862}
863
864fn signed_timestamp(
865    resolved: &ResolvedChannel,
866    event_id: &str,
867    emitted_by: &str,
868) -> SignedTimestamp {
869    let at = crate::clock_mock::now_utc();
870    let at_ms = harn_clock::offset_datetime_to_ms(at);
871    let at_text = at.format(&Rfc3339).unwrap_or_else(|_| at.to_string());
872    let material = format!(
873        "harn.channel.timestamp.v1\nat_ms={at_ms}\nid={event_id}\nname={}\nscope={}\nscope_id={}\nemitted_by={emitted_by}\n",
874        resolved.resolved_name,
875        resolved.scope.as_str(),
876        resolved.scope_id
877    );
878    let signature = hex::encode(crate::connectors::hmac::hmac_sha256(
879        signing_salt(),
880        material.as_bytes(),
881    ));
882    SignedTimestamp {
883        at_ms,
884        at: at_text,
885        algorithm: "hmac-sha256".to_string(),
886        key_id: "local-session".to_string(),
887        signature: format!("sha256:{signature}"),
888    }
889}
890
891fn signing_salt() -> &'static [u8] {
892    SIGNING_SALT
893        .get_or_init(|| {
894            format!(
895                "harn-channel-signing-salt:{}:{}",
896                std::process::id(),
897                uuid::Uuid::now_v7()
898            )
899            .into_bytes()
900        })
901        .as_slice()
902}
903
904/// CH-07 (#1878): signed timestamp for a channel match. Mirrors the emit
905/// timestamp algorithm in `signed_timestamp` so the replay oracle can
906/// verify match timestamps with the same `signing_salt()` key material.
907/// Including `event_id` + `trigger_id` in the signing material binds the
908/// timestamp to this specific (emit, binding) pair so a replayed match
909/// can't be re-stamped onto a different binding.
910fn signed_match_timestamp(
911    resolved: &ResolvedChannel,
912    event_id: &str,
913    trigger_id: &str,
914) -> SignedTimestamp {
915    let at = crate::clock_mock::now_utc();
916    let at_ms = harn_clock::offset_datetime_to_ms(at);
917    let at_text = at.format(&Rfc3339).unwrap_or_else(|_| at.to_string());
918    let material = format!(
919        "harn.channel.match_timestamp.v1\nat_ms={at_ms}\nevent_id={event_id}\ntrigger_id={trigger_id}\nname={}\nscope={}\nscope_id={}\n",
920        resolved.resolved_name,
921        resolved.scope.as_str(),
922        resolved.scope_id
923    );
924    let signature = hex::encode(crate::connectors::hmac::hmac_sha256(
925        signing_salt(),
926        material.as_bytes(),
927    ));
928    SignedTimestamp {
929        at_ms,
930        at: at_text,
931        algorithm: "hmac-sha256".to_string(),
932        key_id: "local-session".to_string(),
933        signature: format!("sha256:{signature}"),
934    }
935}
936
937/// CH-07 (#1878): SHA-256 of the canonical JSON encoding of a channel
938/// emit payload, via [`crate::canonical_json`] (object keys sorted
939/// recursively, array order preserved). Used by the replay oracle to
940/// detect producer-side drift (`HARN-REP-CHN-002`).
941pub fn channel_payload_hash(payload: &serde_json::Value) -> String {
942    let canonical = crate::canonical_json::to_string(payload);
943    let digest = Sha256::digest(canonical.as_bytes());
944    format!("sha256:{}", hex::encode(digest))
945}
946
947/// CH-07 (#1878): append a channel audit receipt to the durable
948/// `lifecycle.channel.audit` topic. Mirrors `emit_pool_*_receipt`
949/// (PL-06 / #1891). The audit log uses `active_event_log()` so receipts
950/// inherit the pipeline's durability (in-memory in tests; durable
951/// SQLite/etc. in production). Best-effort: receipt append errors do not
952/// fail the emit/match — the emit's user-visible receipt is the source
953/// of truth for caller behavior. Audit consumers learn about gaps via
954/// the canonical event-log read API.
955async fn append_channel_audit_event(
956    kind: &'static str,
957    schema: &'static str,
958    payload: serde_json::Value,
959) {
960    let topic = match Topic::new(CHANNEL_AUDIT_TOPIC) {
961        Ok(topic) => topic,
962        Err(_) => return,
963    };
964    let log = active_event_log()
965        .unwrap_or_else(|| install_memory_for_current_thread(CHANNEL_QUEUE_DEPTH));
966    let mut headers = BTreeMap::new();
967    headers.insert("schema".to_string(), schema.to_string());
968    let _ = log
969        .append(&topic, LogEvent::new(kind, payload).with_headers(headers))
970        .await;
971}
972
973/// CH-11 (#1911): emit a guardrail-blocked / -warned audit entry to
974/// the durable channel-audit topic AND to the in-process lifecycle
975/// audit log. The lifecycle entry is what `pipeline_lifecycle_audit_log_take()`
976/// surfaces to test fixtures; the event-log entry is what production
977/// audit consumers tail. Both echo the verbatim payload (callers
978/// concerned about PII should layer `redact::*` on top of the
979/// guardrail). Best-effort; audit write errors do not propagate.
980async fn record_guardrail_audit(
981    kind: &'static str,
982    resolved: &ResolvedChannel,
983    event_id: &str,
984    emitted_by: &str,
985    payload: &serde_json::Value,
986    fired: &[crate::channel_guardrails::FiredGuardrail],
987) {
988    let fired_json: Vec<serde_json::Value> = fired
989        .iter()
990        .map(|entry| {
991            serde_json::json!({
992                "id": entry.id,
993                "kind": entry.kind,
994                "verdict_label": entry.verdict_label,
995                "reason": entry.reason,
996            })
997        })
998        .collect();
999    let audit_payload = serde_json::json!({
1000        "event_id": event_id,
1001        "name_resolved": resolved.resolved_name,
1002        "scope": resolved.scope.as_str(),
1003        "scope_id": resolved.scope_id,
1004        "emitted_by": emitted_by,
1005        "payload_hash": channel_payload_hash(payload),
1006        "payload": payload,
1007        "fired": fired_json,
1008    });
1009    append_channel_audit_event(kind, CHANNEL_GUARDRAIL_AUDIT_SCHEMA, audit_payload.clone()).await;
1010    // Mirror to the lifecycle audit log so tests using
1011    // `pipeline_lifecycle_audit_log_take()` see the entry without
1012    // having to scan the event log directly.
1013    crate::orchestration::record_lifecycle_audit(kind, audit_payload);
1014}
1015
1016/// CH-11 (#1911): record a warning audit for every Warn verdict that
1017/// fired. A no-op when there were no Warn-level fires (the common
1018/// case). Called BEFORE the durable append so the warning order
1019/// matches the dispatch order.
1020async fn record_guardrail_warnings(
1021    resolved: &ResolvedChannel,
1022    event_id: &str,
1023    emitted_by: &str,
1024    payload: &serde_json::Value,
1025    fired: &[crate::channel_guardrails::FiredGuardrail],
1026) {
1027    if fired.is_empty() {
1028        return;
1029    }
1030    record_guardrail_audit(
1031        CHANNEL_GUARDRAIL_WARNING_KIND,
1032        resolved,
1033        event_id,
1034        emitted_by,
1035        payload,
1036        fired,
1037    )
1038    .await;
1039}
1040
1041/// CH-11 (#1911): record a `channel_guardrail_blocked` audit and
1042/// return the synthetic "blocked" receipt so the caller can
1043/// distinguish a guardrail block from a successful append or an
1044/// idempotent dedupe. No durable journal append happens for a blocked
1045/// emit; the audit log entry IS the durable artifact.
1046async fn handle_blocked_emit(
1047    raw_name: &str,
1048    resolved: &ResolvedChannel,
1049    event_id: &str,
1050    emitted_by: &str,
1051    emitted_at: &SignedTimestamp,
1052    payload: &serde_json::Value,
1053    decision: &crate::channel_guardrails::GuardrailDecision,
1054) -> Result<VmValue, VmError> {
1055    record_guardrail_audit(
1056        CHANNEL_GUARDRAIL_BLOCKED_KIND,
1057        resolved,
1058        event_id,
1059        emitted_by,
1060        payload,
1061        decision.fired.as_slice(),
1062    )
1063    .await;
1064    let block_reason = decision
1065        .fired
1066        .iter()
1067        .rev()
1068        .find_map(|f| {
1069            if f.verdict_label == CHANNEL_GUARDRAIL_BLOCKED_KIND
1070                || f.verdict_label.contains("block")
1071            {
1072                Some(f.reason.clone())
1073            } else {
1074                None
1075            }
1076        })
1077        .unwrap_or_else(|| "guardrail blocked".to_string());
1078    let fired_json: Vec<serde_json::Value> = decision
1079        .fired
1080        .iter()
1081        .map(|entry| {
1082            serde_json::json!({
1083                "id": entry.id,
1084                "kind": entry.kind,
1085                "verdict_label": entry.verdict_label,
1086                "reason": entry.reason,
1087            })
1088        })
1089        .collect();
1090    let receipt = serde_json::json!({
1091        "event_id": event_id,
1092        "cursor": serde_json::Value::Null,
1093        "id": event_id,
1094        "name": raw_name,
1095        "name_resolved": resolved.resolved_name,
1096        "scope": resolved.scope.as_str(),
1097        "scope_id": resolved.scope_id,
1098        "emitted_at": emitted_at,
1099        "emitted_by": emitted_by,
1100        "retention": resolved.retention,
1101        "topic": resolved.topic.as_str(),
1102        "inserted": false,
1103        "duplicate": false,
1104        "blocked": true,
1105        "block_reason": block_reason,
1106        "guardrail_fired": fired_json,
1107    });
1108    Ok(crate::stdlib::json_to_vm_value(&receipt))
1109}
1110
1111/// CH-07 (#1878): build + persist the emit receipt on the audit topic.
1112/// Called from `emit_channel_from_vm` immediately after the durable
1113/// journal append succeeds (whether the append was fresh or
1114/// idempotent-suppressed — both outcomes are first-class audit signals).
1115async fn record_channel_emit_receipt(
1116    record: &StoredChannelEvent,
1117    resolved: &ResolvedChannel,
1118    inserted: bool,
1119    span_id: u64,
1120) {
1121    let receipt = ChannelEmitReceipt {
1122        event_id: record.id.clone(),
1123        name_resolved: resolved.resolved_name.clone(),
1124        scope: resolved.scope.as_str().to_string(),
1125        scope_id: resolved.scope_id.clone(),
1126        payload_hash: channel_payload_hash(&record.payload),
1127        payload: record.payload.clone(),
1128        emitted_at: record.emitted_at.clone(),
1129        emitted_by: record.emitted_by.clone(),
1130        pipeline_id: record.pipeline_id.clone(),
1131        session_id: record.session_id.clone(),
1132        tenant_id: record.tenant_id.clone(),
1133        topic: resolved.topic.as_str().to_string(),
1134        inserted,
1135        span_id: if span_id == 0 { None } else { Some(span_id) },
1136    };
1137    let payload = match serde_json::to_value(&receipt) {
1138        Ok(value) => value,
1139        Err(_) => return,
1140    };
1141    append_channel_audit_event(
1142        CHANNEL_EMIT_RECEIPT_KIND,
1143        CHANNEL_EMIT_RECEIPT_SCHEMA,
1144        payload,
1145    )
1146    .await;
1147}
1148
1149/// CH-07 (#1878): build + persist the match receipt on the audit topic.
1150/// Called from `fire_channel_match` after the dispatcher returns, so
1151/// `handler_result` reflects the recorded outcome (succeeded / failed /
1152/// dlq / cancelled / ...) and replay tooling can verify the same handler
1153/// shape fires on every replay.
1154#[allow(clippy::too_many_arguments)]
1155async fn record_channel_match_receipt(
1156    trigger_id: &str,
1157    binding_key: &str,
1158    handler_kind: &str,
1159    resolved: &ResolvedChannel,
1160    event_id: &str,
1161    matched_in_session_id: Option<&str>,
1162    batch: Option<ChannelMatchBatchInfo>,
1163    span_id: u64,
1164    dispatch_outcome: &Result<crate::triggers::DispatchOutcome, crate::triggers::DispatchError>,
1165) {
1166    let receipt = ChannelMatchReceipt {
1167        event_id: event_id.to_string(),
1168        trigger_id: trigger_id.to_string(),
1169        binding_key: binding_key.to_string(),
1170        name_resolved: resolved.resolved_name.clone(),
1171        scope: resolved.scope.as_str().to_string(),
1172        scope_id: resolved.scope_id.clone(),
1173        matched_at: signed_match_timestamp(resolved, event_id, trigger_id),
1174        matched_in_session_id: matched_in_session_id.map(|s| s.to_string()),
1175        batch,
1176        handler_kind: handler_kind.to_string(),
1177        handler_result: ChannelMatchResultSummary::from_dispatch(dispatch_outcome),
1178        span_id: if span_id == 0 { None } else { Some(span_id) },
1179    };
1180    let payload = match serde_json::to_value(&receipt) {
1181        Ok(value) => value,
1182        Err(_) => return,
1183    };
1184    append_channel_audit_event(
1185        CHANNEL_MATCH_RECEIPT_KIND,
1186        CHANNEL_MATCH_RECEIPT_SCHEMA,
1187        payload,
1188    )
1189    .await;
1190}
1191
1192/// CH-07 (#1878): extract `ChannelMatchBatchInfo` from the transcript
1193/// `batch_summary` JSON the dispatcher already builds for batched
1194/// triggers. Returns `None` for non-batched dispatch.
1195fn batch_info_from_summary(
1196    batch_summary: Option<&serde_json::Value>,
1197) -> Option<ChannelMatchBatchInfo> {
1198    let summary = batch_summary?.as_object()?;
1199    let count = summary
1200        .get("count")
1201        .and_then(|v| v.as_u64())
1202        .map(|n| n as usize)?;
1203    let constituent_event_ids = summary
1204        .get("constituent_event_ids")
1205        .and_then(|v| v.as_array())
1206        .map(|arr| {
1207            arr.iter()
1208                .filter_map(|v| v.as_str().map(|s| s.to_string()))
1209                .collect()
1210        })
1211        .unwrap_or_default();
1212    Some(ChannelMatchBatchInfo {
1213        count,
1214        constituent_event_ids,
1215    })
1216}
1217
1218fn emitted_by(context: &ChannelContext) -> String {
1219    context
1220        .worker_id
1221        .clone()
1222        .or_else(|| context.agent_session_id.clone())
1223        .or_else(|| context.task_id.clone())
1224        .unwrap_or_else(|| "harn".to_string())
1225}
1226
1227fn retention_for_scope(scope: ChannelScope) -> &'static str {
1228    match scope {
1229        ChannelScope::Session => "in_process_session",
1230        ChannelScope::Pipeline => "pipeline_event_log",
1231        ChannelScope::Tenant => "tenant_event_log",
1232        ChannelScope::Org => "org_event_log",
1233    }
1234}
1235
1236fn receipt_value(
1237    topic: &Topic,
1238    event_id: EventId,
1239    event: &LogEvent,
1240    inserted: bool,
1241) -> Result<serde_json::Value, VmError> {
1242    let record = stored_record(event)?;
1243    Ok(serde_json::json!({
1244        "event_id": event_id,
1245        "cursor": event_id,
1246        "id": record.id,
1247        "name": record.name,
1248        "name_resolved": record.name,
1249        "scope": record.scope,
1250        "scope_id": record.scope_id,
1251        "payload": record.payload,
1252        "emitted_at": record.emitted_at,
1253        "emitted_by": record.emitted_by,
1254        "pipeline_id": record.pipeline_id,
1255        "session_id": record.session_id,
1256        "tenant_id": record.tenant_id,
1257        "retention": record.retention,
1258        "ttl_ms": record.ttl_ms,
1259        "topic": topic.as_str(),
1260        "inserted": inserted,
1261        "duplicate": !inserted,
1262    }))
1263}
1264
1265fn event_value(
1266    topic: &Topic,
1267    event_id: EventId,
1268    event: LogEvent,
1269) -> Result<serde_json::Value, VmError> {
1270    let record = stored_record(&event)?;
1271    Ok(serde_json::json!({
1272        "event_id": event_id,
1273        "cursor": event_id,
1274        "topic": topic.as_str(),
1275        "kind": event.kind,
1276        "headers": event.headers,
1277        "occurred_at_ms": event.occurred_at_ms,
1278        "id": record.id,
1279        "name": record.name,
1280        "name_resolved": record.name,
1281        "scope": record.scope,
1282        "scope_id": record.scope_id,
1283        "payload": record.payload,
1284        "emitted_at": record.emitted_at,
1285        "emitted_by": record.emitted_by,
1286        "pipeline_id": record.pipeline_id,
1287        "session_id": record.session_id,
1288        "tenant_id": record.tenant_id,
1289        "retention": record.retention,
1290        "ttl_ms": record.ttl_ms,
1291    }))
1292}
1293
1294fn stored_record(event: &LogEvent) -> Result<StoredChannelEvent, VmError> {
1295    serde_json::from_value(event.payload.clone()).map_err(|error| {
1296        VmError::Runtime(format!(
1297            "channel event store contained malformed channel payload: {error}"
1298        ))
1299    })
1300}
1301
1302fn parse_options(value: Option<&VmValue>, builtin: &str) -> Result<ChannelOptions, VmError> {
1303    let Some(value) = value else {
1304        return Ok(ChannelOptions::default());
1305    };
1306    match value {
1307        VmValue::Nil => Ok(ChannelOptions::default()),
1308        VmValue::Dict(options) => Ok(ChannelOptions {
1309            scope: option_string(options, "scope", builtin)?
1310                .map(|scope| ChannelScope::parse(&scope))
1311                .transpose()
1312                .map_err(VmError::from)?,
1313            id: option_string(options, "id", builtin)?,
1314            tenant_id: option_string(options, "tenant_id", builtin)?,
1315            session_id: option_string(options, "session_id", builtin)?,
1316            pipeline_id: option_string(options, "pipeline_id", builtin)?,
1317            from_cursor: option_non_negative_int(options, "from_cursor", builtin)?
1318                .or(option_non_negative_int(options, "cursor", builtin)?)
1319                .map(|value| value as EventId),
1320            limit: option_non_negative_int(options, "limit", builtin)?.map(|value| value as usize),
1321            ttl_ms: option_duration_ms(options, "ttl", builtin)?,
1322        }),
1323        other => Err(VmError::TypeError(format!(
1324            "{builtin}: options must be a dict or nil, got {}",
1325            other.type_name()
1326        ))),
1327    }
1328}
1329
1330fn required_string(value: Option<&VmValue>, builtin: &str, name: &str) -> Result<String, VmError> {
1331    match value {
1332        Some(VmValue::String(value)) => Ok(value.to_string()),
1333        Some(other) => Err(VmError::TypeError(format!(
1334            "{builtin}: {name} must be a string, got {}",
1335            other.type_name()
1336        ))),
1337        None => Err(VmError::TypeError(format!("{builtin}: missing {name}"))),
1338    }
1339}
1340
1341fn required_consumer_id(value: Option<&VmValue>, builtin: &str) -> Result<ConsumerId, VmError> {
1342    ConsumerId::new(required_string(value, builtin, "consumer_id")?).map_err(channel_log_error)
1343}
1344
1345fn required_event_id(
1346    value: Option<&VmValue>,
1347    builtin: &str,
1348    name: &str,
1349) -> Result<EventId, VmError> {
1350    match value {
1351        Some(VmValue::Int(value)) if *value >= 0 => Ok(*value as EventId),
1352        Some(other) => Err(VmError::TypeError(format!(
1353            "{builtin}: {name} must be a non-negative int, got {}",
1354            other.type_name()
1355        ))),
1356        None => Err(VmError::TypeError(format!("{builtin}: missing {name}"))),
1357    }
1358}
1359
1360fn event_id_to_i64(value: EventId, builtin: &str) -> Result<i64, VmError> {
1361    i64::try_from(value)
1362        .map_err(|_| VmError::Runtime(format!("{builtin}: event id {value} exceeds int range")))
1363}
1364
1365fn option_string(
1366    options: &crate::value::DictMap,
1367    key: &str,
1368    builtin: &str,
1369) -> Result<Option<String>, VmError> {
1370    match options.get(key) {
1371        None | Some(VmValue::Nil) => Ok(None),
1372        Some(VmValue::String(value)) if !value.trim().is_empty() => Ok(Some(value.to_string())),
1373        Some(VmValue::String(_)) => Err(VmError::TypeError(format!(
1374            "{builtin}: options.{key} cannot be empty"
1375        ))),
1376        Some(other) => Err(VmError::TypeError(format!(
1377            "{builtin}: options.{key} must be a string or nil, got {}",
1378            other.type_name()
1379        ))),
1380    }
1381}
1382
1383fn option_non_negative_int(
1384    options: &crate::value::DictMap,
1385    key: &str,
1386    builtin: &str,
1387) -> Result<Option<u64>, VmError> {
1388    match options.get(key) {
1389        None | Some(VmValue::Nil) => Ok(None),
1390        Some(VmValue::Int(value)) if *value >= 0 => Ok(Some(*value as u64)),
1391        Some(other) => Err(VmError::TypeError(format!(
1392            "{builtin}: options.{key} must be a non-negative int or nil, got {}",
1393            other.type_name()
1394        ))),
1395    }
1396}
1397
1398fn option_duration_ms(
1399    options: &crate::value::DictMap,
1400    key: &str,
1401    builtin: &str,
1402) -> Result<Option<i64>, VmError> {
1403    match options.get(key) {
1404        None | Some(VmValue::Nil) => Ok(None),
1405        Some(VmValue::Duration(value)) if *value >= 0 => Ok(Some(*value)),
1406        Some(VmValue::Int(value)) if *value >= 0 => Ok(Some(*value)),
1407        Some(other) => Err(VmError::TypeError(format!(
1408            "{builtin}: options.{key} must be a non-negative duration, int, or nil, got {}",
1409            other.type_name()
1410        ))),
1411    }
1412}
1413
1414fn dict_string(values: &crate::value::DictMap, key: &str) -> Option<String> {
1415    match values.get(key) {
1416        Some(VmValue::String(value)) if !value.is_empty() => Some(value.to_string()),
1417        _ => None,
1418    }
1419}
1420
1421fn channel_log_error(error: crate::event_log::LogError) -> VmError {
1422    VmError::Runtime(format!("channel event log: {error}"))
1423}
1424
1425/// CH-06 (#1877): RAII guard around channel emit/match tracing spans.
1426///
1427/// Mirrors `PoolSpanGuard` (PL-06 / #1891): opens both a thread-local
1428/// Harn span (visible to `trace_spans()`) and an OTel `tracing::Span`
1429/// (visible to the exporter), wires OTel span links via
1430/// `crate::observability::otel::set_span_link`, and closes them both on
1431/// `end()` / `Drop`. Disabled-tracing path is a no-op because
1432/// `crate::tracing::span_start_*` returns id 0 and short-circuits.
1433struct ChannelSpanGuard {
1434    span_id: u64,
1435    otel_span: tracing::Span,
1436}
1437
1438impl ChannelSpanGuard {
1439    fn start(
1440        kind: crate::tracing::SpanKind,
1441        name: String,
1442        links: Vec<crate::tracing::SpanLink>,
1443    ) -> Self {
1444        Self::start_with_parenting(kind, name, links, true)
1445    }
1446
1447    fn start_detached(
1448        kind: crate::tracing::SpanKind,
1449        name: String,
1450        links: Vec<crate::tracing::SpanLink>,
1451    ) -> Self {
1452        Self::start_with_parenting(kind, name, links, false)
1453    }
1454
1455    fn start_with_parenting(
1456        kind: crate::tracing::SpanKind,
1457        name: String,
1458        links: Vec<crate::tracing::SpanLink>,
1459        inherit_parent: bool,
1460    ) -> Self {
1461        let span_id = if inherit_parent {
1462            crate::tracing::span_start_with_links(kind, name.clone(), links.clone())
1463        } else {
1464            crate::tracing::span_start_detached_with_links(kind, name.clone(), links.clone())
1465        };
1466        let otel_span = tracing::info_span!(
1467            target: "harn.vm.channel",
1468            "harn.channel",
1469            harn.kind = kind.as_str(),
1470            harn.name = %name,
1471        );
1472        for link in links {
1473            let trace_id = crate::TraceId(link.trace_id);
1474            let mut attributes: std::collections::HashMap<String, String> =
1475                link.attributes.into_iter().collect();
1476            attributes
1477                .entry("harn.link.kind".to_string())
1478                .or_insert_with(|| "channel_emit".to_string());
1479            let _ = crate::observability::otel::set_span_link(
1480                &otel_span,
1481                &trace_id,
1482                &link.span_id,
1483                Some(attributes),
1484            );
1485        }
1486        Self { span_id, otel_span }
1487    }
1488
1489    fn link(&self) -> Option<crate::tracing::SpanLink> {
1490        crate::observability::otel::current_span_context_hex(&self.otel_span)
1491            .map(|(trace_id, span_id)| crate::tracing::SpanLink::new(trace_id, span_id))
1492            .or_else(|| crate::tracing::span_link(self.span_id))
1493    }
1494
1495    fn set_metadata(&self, key: &str, value: serde_json::Value) {
1496        crate::tracing::span_set_metadata(self.span_id, key, value);
1497    }
1498
1499    fn end(&mut self) {
1500        if self.span_id != 0 {
1501            crate::tracing::span_end(self.span_id);
1502            self.span_id = 0;
1503        }
1504    }
1505}
1506
1507impl Drop for ChannelSpanGuard {
1508    fn drop(&mut self) {
1509        self.end();
1510    }
1511}
1512
1513/// CH-06 (#1877): summarize an emit payload for the transcript event so
1514/// downstream renderers / audit feeds don't have to ingest the full
1515/// payload. Numbers/bools render verbatim; strings are truncated; dicts
1516/// and lists collapse to their top-level field count. Keeps the
1517/// transcript log compact while preserving enough context for human
1518/// inspection.
1519fn summarize_payload(payload: &serde_json::Value) -> serde_json::Value {
1520    const MAX_STRING_LEN: usize = 120;
1521    match payload {
1522        serde_json::Value::Null => serde_json::json!({"kind": "null"}),
1523        serde_json::Value::Bool(value) => serde_json::json!({"kind": "bool", "value": value}),
1524        serde_json::Value::Number(value) => serde_json::json!({"kind": "number", "value": value}),
1525        serde_json::Value::String(value) => {
1526            let truncated: String = value.chars().take(MAX_STRING_LEN).collect();
1527            let len = value.chars().count();
1528            serde_json::json!({
1529                "kind": "string",
1530                "value": truncated,
1531                "truncated": len > MAX_STRING_LEN,
1532                "length": len,
1533            })
1534        }
1535        serde_json::Value::Array(items) => {
1536            serde_json::json!({"kind": "array", "length": items.len()})
1537        }
1538        serde_json::Value::Object(map) => {
1539            let fields: Vec<&String> = map.keys().take(8).collect();
1540            serde_json::json!({
1541                "kind": "object",
1542                "field_count": map.len(),
1543                "fields": fields,
1544            })
1545        }
1546    }
1547}
1548
1549/// CH-06 (#1877): append a channel transcript lifecycle event onto the
1550/// active event log. No-op when no log is installed (e.g. unit tests
1551/// running outside a VM context) so emission stays infallible from the
1552/// caller's perspective.
1553fn emit_channel_transcript_event(kind: &'static str, payload: serde_json::Value) {
1554    let Some(log) = active_event_log() else {
1555        return;
1556    };
1557    let Ok(topic) = Topic::new(CHANNEL_TRANSCRIPT_TOPIC) else {
1558        return;
1559    };
1560    let event = LogEvent::new(kind, payload);
1561    if tokio::runtime::Handle::try_current().is_ok() {
1562        if let Ok(join) = std::thread::Builder::new()
1563            .name("harn-channel-transcript".to_string())
1564            .spawn(move || {
1565                let _ = futures::executor::block_on(log.append(&topic, event));
1566            })
1567        {
1568            let _ = join.join();
1569        }
1570    } else {
1571        let _ = futures::executor::block_on(log.append(&topic, event));
1572    }
1573}
1574
1575/// CH-06 (#1877): emit the `transcript.channel.emit` lifecycle event the
1576/// moment the durable append succeeds (whether the append was fresh or
1577/// idempotent). Carries the emit span id when tracing is on so the
1578/// transcript log can be stitched against the OTel trace.
1579fn emit_channel_emit_transcript(
1580    record: &StoredChannelEvent,
1581    resolved: &ResolvedChannel,
1582    inserted: bool,
1583    span_id: u64,
1584) {
1585    let payload = serde_json::json!({
1586        "event_id": record.id,
1587        "name": record.name,
1588        "name_resolved": resolved.resolved_name,
1589        "scope": record.scope,
1590        "scope_id": record.scope_id,
1591        "payload_summary": summarize_payload(&record.payload),
1592        "emitted_at": record.emitted_at,
1593        "emitted_at_ms": record.emitted_at.at_ms,
1594        "emitted_by": record.emitted_by,
1595        "session_id": record.session_id,
1596        "pipeline_id": record.pipeline_id,
1597        "tenant_id": record.tenant_id,
1598        "inserted": inserted,
1599        "duplicate": !inserted,
1600        "span_id": if span_id == 0 { serde_json::Value::Null } else { serde_json::json!(span_id) },
1601    });
1602    emit_channel_transcript_event(CHANNEL_EMIT_TRANSCRIPT_KIND, payload);
1603}
1604
1605/// CH-06 (#1877): emit the `transcript.channel.match` lifecycle event
1606/// just before the dispatcher invokes the handler. Carries the match
1607/// span id and, for batched triggers, the constituent event ids so the
1608/// transcript can render the full batch context inline.
1609#[allow(clippy::too_many_arguments)]
1610fn emit_channel_match_transcript(
1611    trigger_id: &str,
1612    handler_kind: &str,
1613    resolved: &ResolvedChannel,
1614    event_id: &str,
1615    matched_at_ms: i64,
1616    matched_in_session_id: Option<&str>,
1617    span_id: u64,
1618    batch: Option<serde_json::Value>,
1619) {
1620    let mut payload = serde_json::json!({
1621        "event_id": event_id,
1622        "name_resolved": resolved.resolved_name,
1623        "scope": resolved.scope.as_str(),
1624        "scope_id": resolved.scope_id,
1625        "trigger_id": trigger_id,
1626        "handler_kind": handler_kind,
1627        "matched_at_ms": matched_at_ms,
1628        "matched_in_session_id": matched_in_session_id,
1629        "span_id": if span_id == 0 { serde_json::Value::Null } else { serde_json::json!(span_id) },
1630    });
1631    if let Some(batch) = batch {
1632        if let Some(map) = payload.as_object_mut() {
1633            map.insert("batch".to_string(), batch);
1634        }
1635    }
1636    emit_channel_transcript_event(CHANNEL_MATCH_TRANSCRIPT_KIND, payload);
1637}
1638
1639/// CH-06 (#1877): collect the originating-emit span links stashed on a
1640/// batch of `TriggerEvent`s. The non-batched dispatch path uses the
1641/// single-event variant directly; the aggregated/batched path threads
1642/// the per-event headers through the buffer and rebuilds the link list
1643/// here so the resulting `ChannelMatch` span multi-links to every
1644/// constituent emit.
1645fn emit_links_from_event(event: &TriggerEvent) -> Vec<crate::tracing::SpanLink> {
1646    let mut links = Vec::new();
1647    if let (Some(trace_id), Some(span_id)) = (
1648        event.headers.get(EMIT_TRACE_ID_HEADER),
1649        event.headers.get(EMIT_SPAN_ID_HEADER),
1650    ) {
1651        links.push(
1652            crate::tracing::SpanLink::new(trace_id.clone(), span_id.clone()).with_attributes(
1653                BTreeMap::from([("harn.link.kind".to_string(), "channel_emit".to_string())]),
1654            ),
1655        );
1656    }
1657    links
1658}
1659
1660fn emit_links_from_batch(events: &[TriggerEvent]) -> Vec<crate::tracing::SpanLink> {
1661    let mut links = Vec::new();
1662    for event in events {
1663        links.extend(emit_links_from_event(event));
1664    }
1665    links
1666}
1667
1668fn batch_summary_for_transcript(events: &[TriggerEvent]) -> serde_json::Value {
1669    let constituent_ids: Vec<String> = events.iter().map(|event| event.id.0.clone()).collect();
1670    serde_json::json!({
1671        "count": events.len(),
1672        "constituent_event_ids": constituent_ids,
1673    })
1674}
1675
1676/// Parsed channel-source trigger selector.
1677///
1678/// Trigger DSL strings look like `channel:<scope>:<scope-id>:<name>` with
1679/// shorthand forms for tenant-default and session/pipeline scopes. The
1680/// `scope_id_pattern` is `None` for "current" (e.g. `channel:foo` against
1681/// the trigger's tenant) or `Some("*")` for an explicit wildcard
1682/// (`channel:tenant:*:foo`).
1683#[derive(Clone, Debug, PartialEq, Eq)]
1684pub struct ChannelSelector {
1685    scope: ChannelScope,
1686    scope_id_pattern: ScopeIdPattern,
1687    name: String,
1688}
1689
1690#[derive(Clone, Debug, PartialEq, Eq)]
1691enum ScopeIdPattern {
1692    /// Match the current tenant/session/pipeline of the trigger registry
1693    /// (no explicit scope id supplied in the selector string).
1694    Current,
1695    /// Explicit scope id from the selector string.
1696    Exact(String),
1697    /// Wildcard, e.g. `tenant:*:foo` — match any scope id within the
1698    /// trigger's entitled boundary (today: the current tenant only).
1699    Wildcard,
1700}
1701
1702impl ChannelSelector {
1703    /// Parse a `channel:...` trigger source string.
1704    ///
1705    /// Accepted shapes:
1706    /// - `channel:<name>` — tenant scope (current tenant), exact `<name>`
1707    /// - `channel:session:<name>` — session scope, exact `<name>`
1708    /// - `channel:pipeline:<name>` — pipeline scope, exact `<name>`
1709    /// - `channel:tenant:<tenant-id>:<name>` — explicit tenant
1710    /// - `channel:tenant:*:<name>` — tenant wildcard (within entitlement)
1711    /// - `channel:org:<org-id>:<name>` — explicit org (currently disabled)
1712    pub fn parse(input: &str) -> Result<Self, String> {
1713        let input = input.trim();
1714        let rest = input
1715            .strip_prefix("channel:")
1716            .ok_or_else(|| format!("channel selector must start with `channel:`, got `{input}`"))?;
1717        if rest.is_empty() {
1718            return Err("channel selector cannot be empty after `channel:` prefix".to_string());
1719        }
1720
1721        let (head, tail_opt) = match rest.split_once(':') {
1722            Some((head, tail)) => (head, Some(tail)),
1723            None => (rest, None),
1724        };
1725        let parsed_scope = ChannelScope::parse(head).ok();
1726        match (parsed_scope, tail_opt) {
1727            // `channel:<name>` — tenant default.
1728            (None, _) => {
1729                let name = rest.to_string();
1730                validate_selector_name(&name)?;
1731                Ok(Self {
1732                    scope: ChannelScope::Tenant,
1733                    scope_id_pattern: ScopeIdPattern::Current,
1734                    name,
1735                })
1736            }
1737            (Some(scope @ (ChannelScope::Session | ChannelScope::Pipeline)), Some(name))
1738                if !name.is_empty() =>
1739            {
1740                if name.contains(':') {
1741                    return Err(format!(
1742                        "channel selector `{input}`: {} scope expects `<name>` with no extra colons",
1743                        scope.as_str()
1744                    ));
1745                }
1746                validate_selector_name(name)?;
1747                Ok(Self {
1748                    scope,
1749                    scope_id_pattern: ScopeIdPattern::Current,
1750                    name: name.to_string(),
1751                })
1752            }
1753            (Some(scope @ (ChannelScope::Tenant | ChannelScope::Org)), Some(tail))
1754                if !tail.is_empty() =>
1755            {
1756                let Some((scope_id, name)) = tail.split_once(':') else {
1757                    // `channel:tenant:foo` — treat as `<name>` in tenant default.
1758                    if matches!(scope, ChannelScope::Tenant) {
1759                        validate_selector_name(tail)?;
1760                        return Ok(Self {
1761                            scope,
1762                            scope_id_pattern: ScopeIdPattern::Current,
1763                            name: tail.to_string(),
1764                        });
1765                    }
1766                    return Err(format!(
1767                        "channel selector `{input}`: org scope requires `<org-id>:<name>`"
1768                    ));
1769                };
1770                if scope_id.is_empty() || name.is_empty() {
1771                    return Err(format!(
1772                        "channel selector `{input}`: scope id and name must be non-empty"
1773                    ));
1774                }
1775                validate_selector_name(name)?;
1776                let pattern = if scope_id == "*" {
1777                    ScopeIdPattern::Wildcard
1778                } else {
1779                    ScopeIdPattern::Exact(scope_id.to_string())
1780                };
1781                Ok(Self {
1782                    scope,
1783                    scope_id_pattern: pattern,
1784                    name: name.to_string(),
1785                })
1786            }
1787            (Some(scope), _) => Err(format!(
1788                "channel selector `{input}`: {} scope requires `<name>` segment",
1789                scope.as_str()
1790            )),
1791        }
1792    }
1793
1794    pub fn scope(&self) -> &'static str {
1795        self.scope.as_str()
1796    }
1797
1798    pub fn name(&self) -> &str {
1799        &self.name
1800    }
1801
1802    /// Returns true if the supplied emit (scope, scope_id, name) matches this
1803    /// selector. `current_tenant` lets the matcher resolve the implicit
1804    /// "current tenant" boundary used by both `Current` and `Wildcard` modes.
1805    pub fn matches(&self, scope: &str, scope_id: &str, name: &str, current_tenant: &str) -> bool {
1806        if self.scope.as_str() != scope || self.name != name {
1807            return false;
1808        }
1809        match &self.scope_id_pattern {
1810            ScopeIdPattern::Current => match self.scope {
1811                ChannelScope::Tenant => scope_id == current_tenant,
1812                ChannelScope::Session | ChannelScope::Pipeline => {
1813                    // For session/pipeline, "current" means trigger and emit
1814                    // share a runtime context. In v1 (in-process registry)
1815                    // this is implicit: both producer and consumer run in the
1816                    // same VM, so any scope_id within this scope type matches.
1817                    true
1818                }
1819                ChannelScope::Org => false,
1820            },
1821            ScopeIdPattern::Exact(value) => scope_id == value,
1822            ScopeIdPattern::Wildcard => match self.scope {
1823                ChannelScope::Tenant => true,
1824                // Session/pipeline wildcards aren't entitled yet; org wildcards disabled.
1825                _ => false,
1826            },
1827        }
1828    }
1829}
1830
1831fn validate_selector_name(name: &str) -> Result<(), String> {
1832    if name.trim().is_empty()
1833        || name.contains(':')
1834        || name.chars().any(|ch| ch.is_control() || ch.is_whitespace())
1835    {
1836        return Err(format!("channel selector name `{name}` is malformed"));
1837    }
1838    Ok(())
1839}
1840
1841/// Dispatch a freshly emitted channel event to any registered triggers whose
1842/// `channel:` source selector matches the (scope, scope_id, name) tuple.
1843///
1844/// This is the consumer side of the `emit_channel` ↔ trigger plumbing
1845/// (CH-02 / #1872). Errors from individual handlers do not abort the
1846/// emit; they surface in the dispatcher's DLQ + retry pipeline.
1847///
1848/// CH-04 (#1875): bindings with `batch { count, window, key, expire_action }`
1849/// route events through an aggregation buffer instead of dispatching one
1850/// event at a time. When the buffer hits `count`, the dispatcher fires
1851/// the handler with a batched event (`event.batch` populated). When the
1852/// `window` elapses with fewer than `count` events, the dispatcher
1853/// either fires a partial batch (default) or discards the buffer.
1854async fn dispatch_channel_emit_to_triggers(
1855    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1856    resolved: &ResolvedChannel,
1857    payload: ChannelEventPayload,
1858    emit_link: Option<crate::tracing::SpanLink>,
1859) -> Result<(), VmError> {
1860    // Snapshot matching bindings outside of any async work so the registry
1861    // borrow is short-lived.
1862    let bindings = crate::triggers::registry::channel_bindings_matching(
1863        resolved.scope.as_str(),
1864        &resolved.scope_id,
1865        &payload.name,
1866    );
1867
1868    // CH-04 (#1875): flush any aggregation buffers whose window has
1869    // elapsed BEFORE this emit so old batches go out in order. The
1870    // implicit sweep runs even for emits that don't match any binding so
1871    // a stale buffer can't outlive the trigger lifecycle. Tests can also
1872    // call `flush_trigger_aggregations()` directly for deterministic
1873    // window-expire coverage.
1874    flush_expired_aggregations_inner(ctx).await;
1875
1876    if bindings.is_empty() {
1877        return Ok(());
1878    }
1879    let Some(base_vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
1880        // No host VM (e.g. raw test path); nothing to dispatch.
1881        return Ok(());
1882    };
1883    let log = active_event_log()
1884        .unwrap_or_else(|| install_memory_for_current_thread(CHANNEL_QUEUE_DEPTH));
1885    let dispatcher = crate::triggers::Dispatcher::with_event_log(base_vm, log);
1886    for binding in bindings {
1887        // Filter (#1872 acceptance): JSON-path-equality on payload.
1888        // Applied BEFORE aggregation so the buffer only collects events
1889        // the handler actually cares about.
1890        if let Some(filter_str) = binding.filter.as_ref() {
1891            if !channel_filter_matches(filter_str, &payload.payload) {
1892                continue;
1893            }
1894        }
1895        let event = build_channel_trigger_event(&payload, emit_link.as_ref());
1896
1897        // CH-04 (#1875): aggregation path. When the binding declared
1898        // `batch`, accumulate into the per-(binding, partition_key)
1899        // buffer; only dispatch once the threshold is reached.
1900        if let Some(aggregation_config) = binding.aggregation.as_ref() {
1901            let partition_key = crate::triggers::aggregation::partition_key_for_event(
1902                aggregation_config,
1903                &payload.payload,
1904            );
1905            let binding_key = binding.binding_key();
1906            let outcome = crate::triggers::aggregation::accumulate(
1907                &binding_key,
1908                aggregation_config,
1909                partition_key.as_deref(),
1910                event,
1911            );
1912            if let crate::triggers::aggregation::AccumulateOutcome::Ready(events) = outcome {
1913                // CH-06 (#1877): the ChannelMatch span for a batched
1914                // trigger multi-links to ALL constituent ChannelEmit
1915                // spans so the trace tree shows the aggregation fan-in.
1916                let links = emit_links_from_batch(&events);
1917                let batch_summary = batch_summary_for_transcript(&events);
1918                let batched = match crate::triggers::dispatcher::build_batched_event_public(events)
1919                {
1920                    Ok(batched) => batched,
1921                    Err(error) => {
1922                        return Err(VmError::Runtime(format!(
1923                            "emit_channel aggregation batch: {error}"
1924                        )));
1925                    }
1926                };
1927                fire_channel_match(
1928                    &dispatcher,
1929                    binding.clone(),
1930                    batched,
1931                    resolved,
1932                    links,
1933                    Some(batch_summary),
1934                )
1935                .await;
1936            }
1937            continue;
1938        }
1939
1940        let links = emit_links_from_event(&event);
1941        fire_channel_match(&dispatcher, binding.clone(), event, resolved, links, None).await;
1942    }
1943    Ok(())
1944}
1945
1946/// CH-06 (#1877): open a `ChannelMatch` span, emit the transcript
1947/// lifecycle event, and dispatch the trigger handler. The span links
1948/// back to the originating ChannelEmit span (multi-link for batched
1949/// triggers) via `set_span_link` from P-05 (#1858).
1950async fn fire_channel_match(
1951    dispatcher: &crate::triggers::Dispatcher,
1952    binding: std::sync::Arc<crate::triggers::registry::TriggerBinding>,
1953    event: TriggerEvent,
1954    resolved: &ResolvedChannel,
1955    links: Vec<crate::tracing::SpanLink>,
1956    batch_summary: Option<serde_json::Value>,
1957) {
1958    let trigger_id = binding.id.as_str().to_string();
1959    let handler_kind = binding.handler.kind().to_string();
1960    // CH-07 (#1878): use the channel emit's id (carried as the trigger
1961    // event's `dedupe_key`) so the match receipt links back to the
1962    // original `ChannelEmitReceipt.event_id`. `TriggerEvent::new` mints
1963    // a fresh `trigger_evt_*` id for its own `event.id` field that does
1964    // not correlate to the emit chain.
1965    let event_id = if event.dedupe_key.is_empty() {
1966        event.id.0.clone()
1967    } else {
1968        event.dedupe_key.clone()
1969    };
1970    let mut match_span = ChannelSpanGuard::start_detached(
1971        crate::tracing::SpanKind::ChannelMatch,
1972        format!("channel.match {}", resolved.resolved_name),
1973        links,
1974    );
1975    match_span.set_metadata("event_id", serde_json::json!(event_id));
1976    match_span.set_metadata("trigger_id", serde_json::json!(trigger_id));
1977    match_span.set_metadata("handler_kind", serde_json::json!(handler_kind));
1978    match_span.set_metadata("name_resolved", serde_json::json!(resolved.resolved_name));
1979    if let Some(summary) = batch_summary.as_ref() {
1980        match_span.set_metadata("batch", summary.clone());
1981    }
1982    let span_id = crate::tracing::current_span_id().unwrap_or(0);
1983    let matched_at_ms = harn_clock::offset_datetime_to_ms(crate::clock_mock::now_utc());
1984    let matched_in_session_id = crate::agent_sessions::current_session_id()
1985        .or_else(|| event.tenant_id.as_ref().map(|t| t.0.clone()));
1986    emit_channel_match_transcript(
1987        &trigger_id,
1988        &handler_kind,
1989        resolved,
1990        &event_id,
1991        matched_at_ms,
1992        matched_in_session_id.as_deref(),
1993        span_id,
1994        batch_summary.clone(),
1995    );
1996    // CH-07 (#1878): capture the dispatch outcome BEFORE writing the
1997    // match receipt so the receipt records the recorded handler result
1998    // (succeeded/failed/dlq/...) — replay tooling treats the receipt as
1999    // the cached match: on replay the dispatcher looks up the receipt
2000    // by `event_id` instead of re-evaluating the filter spec.
2001    let dispatch_outcome = dispatcher.dispatch(&binding, event).await;
2002    let binding_key = binding.binding_key();
2003    let batch_info = batch_info_from_summary(batch_summary.as_ref());
2004    record_channel_match_receipt(
2005        &trigger_id,
2006        &binding_key,
2007        &handler_kind,
2008        resolved,
2009        &event_id,
2010        matched_in_session_id.as_deref(),
2011        batch_info,
2012        span_id,
2013        &dispatch_outcome,
2014    )
2015    .await;
2016    // Pre-CH-07 the dispatch error was discarded with `let _ = ...`. The
2017    // CH-07 match receipt now records the failure with
2018    // `dispatch_failed: true` so audit consumers don't lose the signal —
2019    // we keep the same fire-and-forget callsite semantics here.
2020    drop(dispatch_outcome);
2021    match_span.end();
2022}
2023
2024/// Drain all expired aggregation buffers and dispatch them. Exposed as
2025/// the `flush_trigger_aggregations()` builtin so Harn scripts (and the
2026/// production runtime) can deterministically advance window-expire
2027/// processing.
2028pub(crate) async fn flush_expired_aggregations_inner(ctx: Option<&crate::vm::AsyncBuiltinCtx>) {
2029    let expirations = crate::triggers::aggregation::drain_expired_aggregations();
2030    if expirations.is_empty() {
2031        return;
2032    }
2033    let Some(base_vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
2034        return;
2035    };
2036    let log = active_event_log()
2037        .unwrap_or_else(|| install_memory_for_current_thread(CHANNEL_QUEUE_DEPTH));
2038    let dispatcher = crate::triggers::Dispatcher::with_event_log(base_vm, log);
2039    for expired in expirations {
2040        if matches!(
2041            expired.action,
2042            crate::triggers::aggregation::ExpireAction::Discard
2043        ) {
2044            continue;
2045        }
2046        // Resolve the binding by parsing the binding_key (id@vN). Skip
2047        // if the binding has been terminated since the buffer was opened.
2048        let Some((trigger_id, version_str)) = expired.binding_key.rsplit_once("@v") else {
2049            continue;
2050        };
2051        let Ok(version) = version_str.parse::<u32>() else {
2052            continue;
2053        };
2054        let Ok(binding) =
2055            crate::triggers::registry::resolve_live_trigger_binding(trigger_id, Some(version))
2056        else {
2057            continue;
2058        };
2059        // CH-06 (#1877): rebuild the channel-resolved metadata from the
2060        // first buffered event so the ChannelMatch span carries the
2061        // right scope/name even for window-expire flushes.
2062        let resolved_for_match = resolved_from_first_event(&expired.events);
2063        let links = emit_links_from_batch(&expired.events);
2064        let batch_summary = batch_summary_for_transcript(&expired.events);
2065        let batched = match crate::triggers::dispatcher::build_batched_event_public(expired.events)
2066        {
2067            Ok(batched) => batched,
2068            Err(_) => continue,
2069        };
2070        match resolved_for_match {
2071            Some(resolved) => {
2072                fire_channel_match(
2073                    &dispatcher,
2074                    binding,
2075                    batched,
2076                    &resolved,
2077                    links,
2078                    Some(batch_summary),
2079                )
2080                .await;
2081            }
2082            None => {
2083                let _ = dispatcher.dispatch(&binding, batched).await;
2084            }
2085        }
2086    }
2087}
2088
2089/// CH-06 (#1877): synthesize a `ResolvedChannel` from the first buffered
2090/// trigger event when a window-expire flush dispatches without going
2091/// back through `resolve_channel`. Returns `None` if the event payload
2092/// isn't a known channel payload (defensive — should not happen in
2093/// practice since the dispatcher only buffers channel events).
2094fn resolved_from_first_event(events: &[TriggerEvent]) -> Option<ResolvedChannel> {
2095    let first = events.first()?;
2096    let ProviderPayload::Known(KnownProviderPayload::Channel(payload)) = &first.provider_payload
2097    else {
2098        return None;
2099    };
2100    let scope = ChannelScope::parse(&payload.scope).ok()?;
2101    let topic = Topic::new(format!(
2102        "channels.{}.{}.{}",
2103        payload.scope,
2104        sanitize_topic_component(&payload.scope_id),
2105        sanitize_topic_component(&payload.name),
2106    ))
2107    .ok()?;
2108    Some(ResolvedChannel {
2109        scope,
2110        scope_id: payload.scope_id.clone(),
2111        resolved_name: payload.name_resolved.clone(),
2112        topic,
2113        retention: retention_for_scope(scope),
2114    })
2115}
2116
2117fn build_channel_trigger_event(
2118    payload: &ChannelEventPayload,
2119    emit_link: Option<&crate::tracing::SpanLink>,
2120) -> TriggerEvent {
2121    let mut event = TriggerEvent::new(
2122        ProviderId::from("channel"),
2123        "channel.emit",
2124        None,
2125        payload.id.clone(),
2126        payload.tenant_id.clone().map(TenantId::new),
2127        BTreeMap::new(),
2128        ProviderPayload::Known(KnownProviderPayload::Channel(payload.clone())),
2129        SignatureStatus::Unsigned,
2130    );
2131    event.headers.insert(
2132        "harn_channel_name".to_string(),
2133        payload.name_resolved.clone(),
2134    );
2135    event
2136        .headers
2137        .insert("harn_channel_scope".to_string(), payload.scope.clone());
2138    event.headers.insert(
2139        "harn_channel_scope_id".to_string(),
2140        payload.scope_id.clone(),
2141    );
2142    // CH-06 (#1877): stash the ChannelEmit span coordinates on the trigger
2143    // event so the downstream ChannelMatch span can link back via
2144    // `set_span_link` — even after travelling through an aggregation
2145    // buffer or being serialized into a batched envelope.
2146    if let Some(link) = emit_link {
2147        event
2148            .headers
2149            .insert(EMIT_TRACE_ID_HEADER.to_string(), link.trace_id.clone());
2150        event
2151            .headers
2152            .insert(EMIT_SPAN_ID_HEADER.to_string(), link.span_id.clone());
2153    }
2154    event
2155}
2156
2157/// Evaluate the trigger filter spec (CH-02 / #1872) against the channel payload.
2158///
2159/// Supported syntax v1: JSON dict (`{"repo": "harn"}`) — each key is a
2160/// dot-path into the payload that must equality-match the value. Missing
2161/// path = no match. Non-dict filter strings are treated as no-op (return
2162/// true) so we don't regress pre-existing trigger `filter:` semantics that
2163/// reuse this field for other purposes.
2164fn channel_filter_matches(filter_raw: &str, payload: &serde_json::Value) -> bool {
2165    let trimmed = filter_raw.trim();
2166    if trimmed.is_empty() {
2167        return true;
2168    }
2169    let parsed: serde_json::Value = match serde_json::from_str(trimmed) {
2170        Ok(value) => value,
2171        Err(_) => return true,
2172    };
2173    let Some(map) = parsed.as_object() else {
2174        return true;
2175    };
2176    map.iter()
2177        .all(|(key, expected)| match payload_path(payload, key) {
2178            Some(actual) => actual == expected,
2179            None => false,
2180        })
2181}
2182
2183fn payload_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
2184    let mut current = value;
2185    for segment in path.split('.') {
2186        if segment.is_empty() {
2187            return None;
2188        }
2189        current = match current {
2190            serde_json::Value::Object(map) => map.get(segment)?,
2191            _ => return None,
2192        };
2193    }
2194    Some(current)
2195}
2196
2197#[cfg(test)]
2198mod tests;