Skip to main content

cpex_core/elicitation/
payload.rs

1// Location: ./crates/cpex-core/src/elicitation/payload.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `ElicitationPayload` — the unified state struct threaded through the
7// Elicitation hook chain. Same input/accumulator split as
8// `DelegationPayload`:
9//
10//   * **Input** (private — bridge-supplied, never mutated by handlers) —
11//     `operation`, `elicitation_id`, `kind`, `from`, `purpose`, `scope`,
12//     `timeout`, `channel`. Set once by the apl-cpex bridge before
13//     `invoke_entries::<ElicitationHook>`. Read through accessors; no
14//     setters or mutable field access at the module boundary.
15//
16//   * **Accumulating output** (`pub` fields) — `id`, `status`, `outcome`,
17//     `approver`, `intent_id`, `expires_at`, `valid`, `reason`,
18//     `metadata`. Handlers clone the payload, populate the slots relevant
19//     to the `operation`, and return it via `PluginResult::modify_payload`.
20//
21// # Three operations, one payload
22//
23// Unlike delegation (one mint per call), an elicitation has three
24// touch-points across its lifetime — dispatch / check / validate. They
25// share this one payload shape and differ only in which `operation` the
26// bridge sets and which output slots the handler fills:
27//
28//   * `Dispatch`  → handler registers the intent / opens the channel
29//                   backchannel, fills `id` / `approver` / `intent_id` /
30//                   `expires_at` / `status = Pending`.
31//   * `Check`     → handler reads current state, fills `status` (and
32//                   `outcome` when resolved).
33//   * `Validate`  → handler verifies the response is genuine, fills
34//                   `valid` / `approver` / `intent_id` / `reason`.
35//
36// The hours-long human gap lives in the channel (e.g. Keycloak CIBA),
37// never in a handler call — each operation is short and synchronous.
38//
39// # Decoupling from apl-core
40//
41// cpex-core does not depend on apl-core, so this module defines its own
42// `ElicitationOp` / `ElicitationStatusKind` / `ElicitationOutcomeKind`
43// rather than reusing apl-core's `ElicitKind` / `ElicitationStatus`. The
44// apl-cpex bridge maps between the two. `kind` is a free string
45// (`"approval"`, `"confirm"`, …) — the per-kind *validation contract* is
46// the apl-core runtime's job, so the handler only needs it informationally.
47//
48// # Rejection
49//
50// Same as delegation: handlers reject via
51// `PluginResult::deny(PluginViolation::new(code, reason))`. The executor
52// halts the chain and the bridge maps that to an `ElicitationError`.
53
54use std::collections::HashMap;
55
56use serde::{Deserialize, Serialize};
57
58use crate::executor::PipelineResult;
59use crate::impl_plugin_payload;
60
61/// Which of the three elicitation touch-points this invocation is. The
62/// handler dispatches on it to decide what to do and which output slots
63/// to fill.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ElicitationOp {
67    /// First arrival — register the intent / open the backchannel.
68    Dispatch,
69    /// Retry — read current status without blocking.
70    Check,
71    /// Resolved — verify the response is genuine.
72    Validate,
73}
74
75/// Current state of a dispatched elicitation, reported by a `Check`
76/// handler. Mirrors apl-core's `ElicitationStatus` shape without the
77/// dependency.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum ElicitationStatusKind {
81    /// The human has not responded yet.
82    Pending,
83    /// The human responded — see `outcome` for approved/denied.
84    Resolved,
85    /// The elicitation timed out before a response.
86    Expired,
87}
88
89/// The human's decision once an elicitation resolves.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum ElicitationOutcomeKind {
93    Approved,
94    Denied,
95}
96
97/// State threaded through the Elicitation hook chain. See the
98/// module-level docs for the input/accumulator split. Input fields are
99/// private (set once via the constructor + builders, never mutated).
100/// Output fields are `pub` (handlers populate on clones and return the
101/// updated payload).
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ElicitationPayload {
104    // ----- Input (private — bridge-supplied, never mutated by handlers) -----
105    /// Which touch-point this is.
106    operation: ElicitationOp,
107
108    /// Correlation id from a prior `Dispatch`. `None` on dispatch (the
109    /// handler mints it); `Some` on check / validate.
110    elicitation_id: Option<String>,
111
112    /// Elicitation kind (`"approval"`, `"confirm"`, `"step_up"`, …) —
113    /// informational for the handler; the validation contract is enforced
114    /// by the apl-core runtime, not here.
115    kind: String,
116
117    /// Resolved approver identity (the apl-core `from` attr already
118    /// resolved against the request bag by the bridge). For CIBA this is
119    /// the `login_hint`.
120    from: String,
121
122    /// Human-readable description of what's being asked — CIBA
123    /// `binding_message`, audited verbatim. `None` for kinds that carry
124    /// their prompt elsewhere.
125    purpose: Option<String>,
126
127    /// The APL scope expression string, passed through for the handler to
128    /// record alongside the registered intent (the runtime evaluates it,
129    /// not the handler). `None` for kinds without arg binding.
130    scope: Option<String>,
131
132    /// Validity window (e.g. `"24h"`) — CIBA `requested_expiry`. `None`
133    /// defers to the handler's configured default.
134    timeout: Option<String>,
135
136    /// Optional channel label (`"ciba"` / `"slack"` / …) for the handler's
137    /// own logging/telemetry. Not a routing key (the plugin was already
138    /// selected by name).
139    channel: Option<String>,
140
141    // ----- Output (pub — handlers populate via direct assignment on clones) -----
142    /// Correlation id minted on `Dispatch`. The agent echoes it on retry.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub id: Option<String>,
145
146    /// Current status — set by `Check` (and `Dispatch`, which leaves it
147    /// `Pending`).
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub status: Option<ElicitationStatusKind>,
150
151    /// Approved / denied — set by `Check` once `status` is `Resolved`.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub outcome: Option<ElicitationOutcomeKind>,
154
155    /// Resolved approver identity — set by `Dispatch` (when known) and by
156    /// `Validate` (the consenting party, cross-checked against `from`).
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub approver: Option<String>,
159
160    /// Registered intent id (lodging-intent binding) — set by `Dispatch`
161    /// and echoed by `Validate` for audit reconciliation.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub intent_id: Option<String>,
164
165    /// Expiry timestamp (RFC 3339) — set by `Dispatch` when the channel
166    /// reports one.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub expires_at: Option<String>,
169
170    /// Genuineness verdict — set by `Validate`. `true` when the signed
171    /// response validates, its intent binding matches, and the responder
172    /// is the approver. (The runtime layers the scope-over-args check on
173    /// top before honoring an approval.)
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub valid: Option<bool>,
176
177    /// Why a `Check`/`Validate` reported the state it did — failure reason
178    /// when `valid` is `false`, or diagnostic context. `None` on success.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub reason: Option<String>,
181
182    /// Optional handler metadata (telemetry, diagnostics). Not load-bearing.
183    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
184    pub metadata: HashMap<String, serde_json::Value>,
185}
186
187impl ElicitationPayload {
188    /// Construct a payload for the given operation + kind + resolved
189    /// approver. Optional input slots are set via the `.with_*` builders;
190    /// output fields start empty and accumulate as the handler runs.
191    pub fn new(operation: ElicitationOp, kind: impl Into<String>, from: impl Into<String>) -> Self {
192        Self {
193            operation,
194            elicitation_id: None,
195            kind: kind.into(),
196            from: from.into(),
197            purpose: None,
198            scope: None,
199            timeout: None,
200            channel: None,
201            id: None,
202            status: None,
203            outcome: None,
204            approver: None,
205            intent_id: None,
206            expires_at: None,
207            valid: None,
208            reason: None,
209            metadata: HashMap::new(),
210        }
211    }
212
213    // -------- Input builders --------
214
215    /// Set the correlation id (check / validate operations).
216    pub fn with_elicitation_id(mut self, id: impl Into<String>) -> Self {
217        self.elicitation_id = Some(id.into());
218        self
219    }
220
221    pub fn with_purpose(mut self, purpose: impl Into<String>) -> Self {
222        self.purpose = Some(purpose.into());
223        self
224    }
225
226    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
227        self.scope = Some(scope.into());
228        self
229    }
230
231    pub fn with_timeout(mut self, timeout: impl Into<String>) -> Self {
232        self.timeout = Some(timeout.into());
233        self
234    }
235
236    pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
237        self.channel = Some(channel.into());
238        self
239    }
240
241    // -------- Input read accessors --------
242
243    pub fn operation(&self) -> ElicitationOp {
244        self.operation
245    }
246
247    pub fn elicitation_id(&self) -> Option<&str> {
248        self.elicitation_id.as_deref()
249    }
250
251    pub fn kind(&self) -> &str {
252        &self.kind
253    }
254
255    pub fn from(&self) -> &str {
256        &self.from
257    }
258
259    pub fn purpose(&self) -> Option<&str> {
260        self.purpose.as_deref()
261    }
262
263    pub fn scope(&self) -> Option<&str> {
264        self.scope.as_deref()
265    }
266
267    pub fn timeout(&self) -> Option<&str> {
268        self.timeout.as_deref()
269    }
270
271    pub fn channel(&self) -> Option<&str> {
272        self.channel.as_deref()
273    }
274
275    // -------- Host-side application helper --------
276
277    /// Pull the resolved `ElicitationPayload` out of a `PipelineResult`
278    /// returned by `mgr.invoke_entries::<ElicitationHook>(...)`. Returns
279    /// `None` when the pipeline was denied or the result's payload wasn't
280    /// an `ElicitationPayload`. Same contract as
281    /// `DelegationPayload::from_pipeline_result`.
282    pub fn from_pipeline_result(result: &PipelineResult) -> Option<Self> {
283        result
284            .modified_payload
285            .as_ref()
286            .and_then(|p| p.as_any().downcast_ref::<ElicitationPayload>())
287            .cloned()
288    }
289}
290
291impl_plugin_payload!(ElicitationPayload);
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn new_sets_input_and_leaves_output_empty() {
299        let p = ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@example.com")
300            .with_purpose("Approve $25,000 raise")
301            .with_scope("args.amount <= 25000")
302            .with_timeout("24h")
303            .with_channel("ciba");
304
305        assert_eq!(p.operation(), ElicitationOp::Dispatch);
306        assert_eq!(p.kind(), "approval");
307        assert_eq!(p.from(), "alice@example.com");
308        assert_eq!(p.purpose(), Some("Approve $25,000 raise"));
309        assert_eq!(p.scope(), Some("args.amount <= 25000"));
310        assert_eq!(p.timeout(), Some("24h"));
311        assert_eq!(p.channel(), Some("ciba"));
312        assert!(p.elicitation_id().is_none());
313        // Output slots start empty.
314        assert!(p.id.is_none());
315        assert!(p.status.is_none());
316        assert!(p.valid.is_none());
317    }
318
319    #[test]
320    fn with_elicitation_id_sets_correlation() {
321        let p = ElicitationPayload::new(ElicitationOp::Check, "approval", "alice@example.com")
322            .with_elicitation_id("elic-123");
323        assert_eq!(p.elicitation_id(), Some("elic-123"));
324        assert_eq!(p.operation(), ElicitationOp::Check);
325    }
326
327    #[test]
328    fn payload_roundtrips_through_serde() {
329        // The executor clones payloads across handler boundaries via serde
330        // in some paths — confirm a populated output survives a round-trip.
331        let mut p =
332            ElicitationPayload::new(ElicitationOp::Dispatch, "approval", "alice@example.com");
333        p.id = Some("elic-1".into());
334        p.status = Some(ElicitationStatusKind::Pending);
335        p.intent_id = Some("intent-9".into());
336
337        let json = serde_json::to_string(&p).unwrap();
338        let back: ElicitationPayload = serde_json::from_str(&json).unwrap();
339        assert_eq!(back.id.as_deref(), Some("elic-1"));
340        assert_eq!(back.status, Some(ElicitationStatusKind::Pending));
341        assert_eq!(back.intent_id.as_deref(), Some("intent-9"));
342    }
343}