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}