Skip to main content

theway_daemon/trigger_engine/
types.rs

1//! RFC 1 (issue #20) trigger envelope, source taxonomy, authority, state machine, and the
2//! `TriggerRecord` persisted as `SessionTreeEntry::Custom { custom_type: "trigger" }`.
3//!
4//! Moved out of theway-core into the CLI host (`trigger_engine`): the core runtime only
5//! maintains state and exposes the agent loop; external-event-driven invocation — the
6//! envelope types, dedup/cycle runtime, permission hooks, sub-agent execution and result
7//! promotion — is a host-level concern. The host consumes core's public API
8//! (`Session::append_custom`, `Agent::prompt`, harness events) to act on the core state.
9//!
10//! Transport adapters (MCP push, cron, file-watch, webhook) live in `crates/server/src/
11//! triggers` and consume the [`NotificationHook`](super::notification_hook) trait.
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16/// The runtime-facing envelope for a single external event. Constructed by an upstream
17/// adapter (typically inside `crates/harness::triggers`) and handed to
18/// `AgentHarness::handle_trigger(...)`. Once accepted, the runtime persists a
19/// [`TriggerRecord`] derived from this envelope.
20///
21/// `Trigger` is the boundary type between transport-specific source adapters (which know
22/// about webhooks, MCP push frames, WebSocket frames, etc.) and the runtime. Adding new
23/// fields here is additive — readers must tolerate unknown fields per
24/// [`TriggerRecord::SCHEMA_VERSION`] strategy.
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Trigger {
27    /// Typed source descriptor. Lets the rule engine match on adapter family + adapter id.
28    pub source: TriggerSource,
29    /// First-class display dimension. UI groups `/triggers` by this.
30    pub source_kind: SourceKind,
31    /// Human-readable source label supplied by the adapter (e.g. "MCP filesystem").
32    pub source_label: String,
33    /// Human-readable event label supplied by the adapter (e.g. "file changed", "pr merged").
34    pub event_label: String,
35    /// Default-`Local`: only `payload_summary` carries data to the runtime; full `payload`
36    /// is `null`. Sources opt into `Shared` per RFC 0 §2.2.1 / RFC 1 §2.2 #1; `Redacted`
37    /// forces `payload = null` regardless.
38    pub payload_visibility: PayloadVisibility,
39    /// Truncated human-readable summary; bounded by the runtime persist cap (4 KiB).
40    pub payload_summary: Option<String>,
41    /// Source-specific full payload. Default `None` (envelope-only). The runtime always
42    /// truncates to `payload_summary` before persistence.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub payload: Option<serde_json::Value>,
45    /// Required: dedup key. Runtime drops events with a duplicate key within the
46    /// configured dedup window (default 5 minutes).
47    pub idempotency_key: String,
48    /// How the dedup window collapses repeat events sharing this `idempotency_key`. Sources
49    /// declare per-event policy (RFC 1 §5 open decision #4 / §11). Required field — the
50    /// runtime does **not** default to `Drop` on missing field at deserialize time so an
51    /// adapter that forgot to set it surfaces immediately rather than silently dropping
52    /// real events. Adapters that want "no replacement" semantics set [`ReplacementPolicy::Drop`]
53    /// explicitly.
54    pub replacement_policy: ReplacementPolicy,
55    /// Audit lineage. The same `trace_id` propagates to follow-up triggers spawned by the
56    /// agent so cycle suppression can fire after a configurable hop count.
57    pub trace_id: String,
58    /// Authority claim made by the source. The runtime treats this as an audit summary and
59    /// an input to the permission evaluator, NOT as proof that the action is authorized.
60    /// See RFC 1 §2.3 + RFC 4 §4 for the source-vs-action authority separation.
61    pub authority: TriggerAuthority,
62    /// When the runtime received the trigger (set by the adapter before sinking).
63    pub received_at: DateTime<Utc>,
64}
65
66/// Typed source descriptor. Each variant carries enough information for the rule engine to
67/// distinguish triggers from different upstream systems without parsing strings.
68///
69/// Adding a new variant is additive and only needs to be tagged `#[serde(rename_all = ...)]`
70/// to keep wire-stable.
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(tag = "kind", rename_all = "snake_case")]
73pub enum TriggerSource {
74    /// Notification pushed by an MCP server (per RFC 1 §4.2).
75    Mcp { server_name: String, method: String },
76    /// Locally fired event (cron / file-watch / agent self-trigger). Tools-MCP-Lead's
77    /// adapter taxonomy in RFC 4 §2.1 uses concrete `subkind`s; the runtime envelope only
78    /// needs a generic carrier. (`subkind` rather than `kind` because the enum is
79    /// `serde(tag = "kind")` and reserves the latter for the discriminator.)
80    Local { subkind: String },
81    /// An action emitted by another agent in a multi-agent topology (placeholder for
82    /// RFC 2 — runtime accepts the variant today but no rule engine consumes it yet).
83    AgentDelegate {
84        agent_id: String,
85        delegation_id: String,
86    },
87}
88
89/// UI grouping dimension. `/triggers --source <kind>` filters on this.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum SourceKind {
93    Local,
94    Mcp,
95}
96
97/// Privacy tier for the carried payload. Enforced by the runtime when persisting and by
98/// adapters when rendering.
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum PayloadVisibility {
102    /// `payload` is `None`. Only `payload_summary` is available to consumers. Default.
103    Local,
104    /// `payload` may be `Some(...)`. Runtime still truncates to `payload_summary` for
105    /// persistence (4 KiB cap).
106    Shared,
107    /// `payload` is forced to `None` and `payload_summary` must be de-identified.
108    Redacted,
109}
110
111/// Audit / authorization summary attached to every trigger. Token material is **never**
112/// stored here. `principal_id` is opaque-stable (ULID-style); `principal_label` is for
113/// display only; `credential_scope` is the source's declared scope, never used as a secret
114/// lookup key.
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
116pub struct TriggerAuthority {
117    pub principal_id: String,
118    pub principal_label: String,
119    pub credential_scope: CredentialScope,
120    /// Adapter-declared subset of actions the source's credential is scoped for (e.g.
121    /// `["read", "comment"]` for a GitHub installation). The runtime permission evaluator
122    /// MAY intersect this with the local policy when deciding whether to execute a tool
123    /// call.
124    #[serde(default)]
125    pub allowed_source_actions: Vec<String>,
126    /// Source-stated expiry (for short-lived source credentials). Optional; runtime does not act
127    /// on it directly — adapters refresh tokens themselves.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub expires_at: Option<DateTime<Utc>>,
130}
131
132/// How the runtime dedup window collapses repeat events sharing the same
133/// `idempotency_key`. Declared per-event by the source adapter; the runtime applies the
134/// declared policy when it sees a duplicate within the dedup window (default 5 minutes per
135/// RFC 1 §5).
136///
137/// RFC 1 §5 + RFC 1 §11 open decision #4: the field is **required** on the wire — the
138/// runtime does not coerce a missing field into `Drop` so adapters that forgot to set it
139/// fail loud at deserialize time. Adapters that want "ignore subsequent duplicates"
140/// semantics set [`Self::Drop`] explicitly.
141///
142/// Recommended choice per source family:
143/// - MCP `notifications/tools/listChanged` / `notifications/resources/listChanged` →
144///   [`Self::LatestReplaces`] (the latest catalog snapshot supersedes earlier ones).
145/// - MCP `notifications/resources/updated` per resource URI → [`Self::LatestReplaces`].
146/// - Custom MCP notifications without a `_meta.theway_dedup_key` agreement → [`Self::Drop`].
147/// - Webhook-style events where every occurrence matters (e.g. PR comments) →
148///   [`Self::Drop`] keyed by a per-event id.
149#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ReplacementPolicy {
152    /// Replace the in-flight / queued trigger with the latest occurrence. Useful for
153    /// "snapshot of current state" events.
154    LatestReplaces,
155    /// Combine duplicates into one trigger, preserving merged context for the rule layer.
156    /// The runtime treats this identically to [`Self::LatestReplaces`] for v1 (audit
157    /// records both arrivals); future RFC 4 rule actions may use the distinction.
158    Coalesce,
159    /// Drop duplicate occurrences during the dedup window; only the first event in the
160    /// window fires the rule. Default for sources that did not explicitly opt in.
161    Drop,
162}
163
164/// Audit/authorization summary enum shared with provider/auth credential resolution. v1
165/// values are part of the credential-scope contract. The runtime treats this as opaque and
166/// passes it through to the evaluator and the session audit record.
167#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "PascalCase")]
169pub enum CredentialScope {
170    User,
171    Project,
172    Team,
173    Agent,
174    None,
175}
176
177/// Lifecycle state of a single trigger as it moves through the runtime state machine. Maps
178/// 1:1 to the RFC 0 5-stage ack lifecycle, plus the runtime-only `received` / `accepted` /
179/// `deduped` / `cycle_suppressed` / `permission_denied` / `needs_approval` / `running` /
180/// `failed` / `completed` set from RFC 1 §2.7.
181///
182/// `received`, `accepted`, and `running` are transitional; the rest are terminal for the
183/// purposes of `TriggerRecord.state`. See [`Self::is_terminal`] for the canonical predicate.
184#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum TriggerState {
187    /// Frame schema OK + entered local dedup queue. Audit not yet persisted.
188    Received,
189    /// Dedup pass + permission `Allow` or `Prompt` + audit persisted.
190    Accepted,
191    /// Same `idempotency_key` already seen within the dedup window.
192    Deduped,
193    /// Same `trace_id` exceeded the cycle hop cap.
194    CycleSuppressed,
195    /// Permission evaluator returned `Deny`. Terminal, unrecoverable except via policy.
196    PermissionDenied,
197    /// Permission evaluator returned `Prompt`. Soft terminal — UI offers replay.
198    NeedsApproval,
199    /// Agent loop is currently executing the action. Transitional.
200    Running,
201    /// Agent loop or persistence failed mid-execution. Terminal.
202    Failed,
203    /// Action completed normally. Terminal.
204    Completed,
205}
206
207impl TriggerState {
208    /// `true` when the state is one a consumer can wait on without more transitions.
209    pub fn is_terminal(self) -> bool {
210        matches!(
211            self,
212            Self::Deduped
213                | Self::CycleSuppressed
214                | Self::PermissionDenied
215                | Self::NeedsApproval
216                | Self::Failed
217                | Self::Completed
218        )
219    }
220}
221
222/// Persistent audit record written under `SessionTreeEntry::Custom { custom_type: "trigger" }`
223/// per RFC 1 §2.6. Schema is additive-only inside `SCHEMA_VERSION = 1`; breaking changes
224/// bump to v2 with a parallel deserializer.
225///
226/// **Never** contains raw token material. `authority` is the summary attached to the
227/// trigger, not a credential. `payload_summary` is truncated and bounded.
228#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
229pub struct TriggerRecord {
230    /// Frozen at v=1 for the first runtime release. New optional fields are tolerated by
231    /// older readers; breaking changes increment this and gain a parallel v=2 deserializer.
232    pub schema_version: u32,
233    pub source: TriggerSource,
234    pub source_kind: SourceKind,
235    pub source_label: String,
236    pub event_label: String,
237    pub trace_id: String,
238    pub authority: TriggerAuthority,
239    pub idempotency_key: String,
240    pub replacement_policy: ReplacementPolicy,
241    pub received_at: DateTime<Utc>,
242    pub state: TriggerState,
243    pub payload_visibility: PayloadVisibility,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub payload_summary: Option<String>,
246    /// Snapshot of the evaluator decision (Allow / Deny { reason } / Prompt { ... }) at the
247    /// moment the trigger was admitted. Opaque JSON so the evaluator schema can evolve
248    /// without breaking the audit record.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub evaluator_decision: Option<serde_json::Value>,
251    /// Opaque local id pointing to the follow-up `SessionTreeEntry::Message` produced by
252    /// handling this trigger. Filled after the agent loop finalises.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub result_link: Option<String>,
255    /// Set by future RFC 4 work to associate the trigger with the rule that fired. The
256    /// runtime persists whatever the caller passes; rule attribution is an upstream concern.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub rule_name: Option<String>,
259}
260
261impl TriggerRecord {
262    /// Current schema version. Bump only on breaking changes.
263    pub const SCHEMA_VERSION: u32 = 1;
264
265    /// Construct an in-progress record from a `Trigger`. The runtime fills `state` /
266    /// `evaluator_decision` / `result_link` as the trigger advances; this helper produces
267    /// the initial `Received` snapshot suitable for the first persistence step.
268    pub fn received_from(trigger: &Trigger) -> Self {
269        Self {
270            schema_version: Self::SCHEMA_VERSION,
271            source: trigger.source.clone(),
272            source_kind: trigger.source_kind,
273            source_label: trigger.source_label.clone(),
274            event_label: trigger.event_label.clone(),
275            trace_id: trigger.trace_id.clone(),
276            authority: trigger.authority.clone(),
277            idempotency_key: trigger.idempotency_key.clone(),
278            replacement_policy: trigger.replacement_policy,
279            received_at: trigger.received_at,
280            state: TriggerState::Received,
281            payload_visibility: trigger.payload_visibility,
282            payload_summary: trigger.payload_summary.clone(),
283            evaluator_decision: None,
284            result_link: None,
285            rule_name: None,
286        }
287    }
288
289    /// `custom_type` tag the runtime uses when writing this record under
290    /// `SessionTreeEntry::Custom`. Stable identifier for downstream readers.
291    pub const CUSTOM_TYPE: &'static str = "trigger";
292}
293
294#[cfg(test)]
295// Test files live in `tests/trigger_engine/types/` (mirror of src), pulled in by
296// path so they keep unit-test semantics (private access). See docs/rust-test-files.md.
297tests_bridge_macro::tests_bridge!("trigger_engine/types");