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
28pub(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
35pub 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";
44const CHANNEL_EMIT_RECEIPT_SCHEMA: &str = "harn.channel_emit_receipt.v1";
47const CHANNEL_MATCH_RECEIPT_SCHEMA: &str = "harn.channel_match_receipt.v1";
48
49pub(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
57const 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#[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#[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
226pub struct ChannelMatchBatchInfo {
227 pub count: usize,
228 pub constituent_event_ids: Vec<String>,
229}
230
231#[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 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 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 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 emit_channel_emit_transcript(&record, &resolved, outcome.inserted, emit_span_id);
430 record_channel_emit_receipt(&record, &resolved, outcome.inserted, emit_span_id).await;
435 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 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 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
904fn 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
937pub 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
947async 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
973async 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 crate::orchestration::record_lifecycle_audit(kind, audit_payload);
1014}
1015
1016async 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
1041async 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
1111async 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#[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
1192fn 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
1425struct 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
1513fn 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
1549fn 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
1575fn 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#[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
1639fn 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#[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 Current,
1695 Exact(String),
1697 Wildcard,
1700}
1701
1702impl ChannelSelector {
1703 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 (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 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 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 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 _ => 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
1841async 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 let bindings = crate::triggers::registry::channel_bindings_matching(
1863 resolved.scope.as_str(),
1864 &resolved.scope_id,
1865 &payload.name,
1866 );
1867
1868 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 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 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 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 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
1946async 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 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 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 drop(dispatch_outcome);
2021 match_span.end();
2022}
2023
2024pub(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 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 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
2089fn 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 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
2157fn 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;