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");