apl_core/step.rs
1// Location: ./crates/apl-core/src/step.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Policy-phase Step IR and async dispatch traits.
7//
8// The DSL allows policy:/post_policy: lists to contain three kinds of
9// entries beyond predicate-and-action rules:
10//
11// - PDP calls: `cedar:(...)`, `opa(...)`, `authzen(...)`, `nemo(...)`,
12// `cel:(...)` with optional `on_deny:` / `on_allow:` reaction blocks
13// - Plugin invocations: `plugin(name)`
14// - Taint effects: `taint(label[, scope])`
15//
16// `Step` is the union over these forms plus the existing `Rule`. The async
17// `evaluate_steps` function walks a Step list, dispatching PDP calls via
18// `PdpResolver` and plugin calls via `PluginInvoker`. Taint dispatch is
19// recognized but no-op in apl-core — actual SessionStore writes happen in
20// `apl-cpex`, which has access to that machinery.
21//
22// Grounded in apl-dsl-spec.md §3 (effects) / §7 (PDP integration) and
23// apl-design.md §8.1 (PdpResolver seam).
24
25use async_trait::async_trait;
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29use crate::evaluator::Decision;
30use crate::pipeline::{TaintEvent, TaintScope};
31use crate::rules::Rule;
32
33/// Parser-internal intermediate IR. After the parser builds a Step
34/// tree, `parser::step_to_top_level_effect` converts it into the
35/// unified [`crate::rules::Effect`] used by the evaluator + every
36/// public entry point.
37///
38/// `Step` exists only because `parse_step` builds its nodes
39/// incrementally and the conversion to `Effect::When` /
40/// `Effect::Pdp` happens at the top of `compile_apl_blocks` once
41/// the source position is known. Not part of the public API as of
42/// E4 — external code dispatches on `Effect` everywhere.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub(crate) enum Step {
46 /// Predicate-and-action rule (the existing 5a/5b/5c case).
47 Rule(Rule),
48
49 /// External PDP call. `on_deny` / `on_allow` are reaction Step lists
50 /// that fire based on the PDP's decision (DSL §7.5).
51 Pdp {
52 call: PdpCall,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 on_deny: Vec<Step>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 on_allow: Vec<Step>,
57 },
58
59 /// `plugin(name)` — invoke a CPEX-registered plugin. The plugin's
60 /// `PluginResult` decision becomes the step's outcome.
61 Plugin { name: String },
62
63 /// `delegate: { plugin: ..., ... }` — mint a downstream delegation
64 /// token via a TokenDelegateHook plugin. Populates
65 /// `delegation.granted_*` attributes in the bag so subsequent
66 /// rules in the same step list can read them. See
67 /// `docs/apl-identity-delegation-design.md`.
68 Delegate(DelegateStep),
69
70 /// `taint(label[, scope])` — apply a taint label. Always succeeds;
71 /// never produces a Deny. SessionStore dispatch happens in apl-cpex.
72 Taint {
73 label: String,
74 scopes: Vec<TaintScope>,
75 },
76
77 /// `require_approval(...)` / `confirm(...)` / … — dispatch an
78 /// elicitation to a human and resume once resolved. The elicitation
79 /// analogue of `Delegate`; resolution is dispatched to an
80 /// `ElicitationHandler` plugin via apl-cpex. See
81 /// `docs/apl-manager-approval-ciba-design.md`.
82 Elicit(ElicitStep),
83}
84
85/// One delegation invocation inside `pre_invocation:` or `post_invocation:`.
86///
87/// At runtime the apl-cpex `DelegationInvoker` constructs a
88/// `cpex_core::delegation::DelegationPayload` from
89/// * the inbound bearer token (pulled from
90/// `Extensions.raw_credentials.inbound_tokens`),
91/// * this step's `args` (target / audience / permissions / mode /
92/// attenuation, layered over the plugin's configured defaults),
93/// * extensions-derived context (subject, prior delegation chain),
94///
95/// then calls `manager.invoke_entries::<TokenDelegateHook>(...)`. On
96/// success the resulting `delegated_token` is written into
97/// `Extensions.raw_credentials.delegated_tokens.*` and the granted
98/// scopes / audience surface as `delegation.granted.*` attributes
99/// in the policy bag for downstream rules to inspect.
100///
101/// `args` is a free-form map because each delegation backend has its
102/// own typed config shape; apl-core treats it as opaque and hands it
103/// to the plugin via the existing per-call config-override pathway.
104///
105/// # Multiple `delegate(...)` in one phase (most-recent-wins)
106///
107/// Multiple `delegate(...)` steps in the same phase are supported —
108/// each fires independently, each contributes to `Extensions`
109/// (`raw_credentials.delegated_tokens` is a HashMap keyed on
110/// audience+scope+mode so tokens accumulate; `delegation.chain`
111/// grows with each hop). But the `delegation.granted.*` bag keys
112/// are **overwritten** on each call — only the most recent
113/// delegate's grants are queryable from downstream `require(...)`
114/// rules.
115///
116/// For fan-out flows that need multiple independently-queryable
117/// grants, split into `pre_invocation:` + `post_invocation:` or reach for a
118/// future per-step `as:` alias (not in v0; see the design doc's
119/// "Open design questions" section).
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct DelegateStep {
122 /// Plugin name — must reference an entry in the top-level
123 /// `plugins:` block that registers under the `token.delegate`
124 /// hook.
125 pub plugin_name: String,
126
127 /// Per-call config overrides applied for this delegation only.
128 /// Layered on top of the plugin's default config; the framework's
129 /// `build_override_entries` plumbing handles the merge.
130 /// Common keys: `target`, `audience`, `permissions`, `mode`,
131 /// `attenuation`. Schema is plugin-defined.
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub config_override: Option<serde_yaml::Value>,
134
135 /// `deny | continue` — what to do when the plugin returns a
136 /// deny (e.g. IdP refusal, network error). `None` defaults to
137 /// `"deny"` (fail-closed; matches PDP step semantics).
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub on_error: Option<String>,
140
141 /// Human-readable source path (e.g.
142 /// `"route.get_compensation.policy[2]"`) — used in audit and
143 /// `Decision::Deny.rule_source` when the step denies.
144 pub source: String,
145}
146
147/// The kind of elicitation — selects which validation contract the
148/// runtime applies to the human's response. A single AST node
149/// ([`Step::Elicit`]) covers every kind; the DSL exposes each via a
150/// sugar verb (`require_approval` → `Approval`, `confirm` → `Confirm`,
151/// …) that all parse to the same node. See
152/// `docs/apl-elicitation-hook-design.md` for the per-kind contracts.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum ElicitKind {
156 /// Yes/no decision from a designated approver (manager approval).
157 /// The approver MAY differ from the request subject; the response is
158 /// bound to the request's args via `scope`.
159 Approval,
160 /// Cheap yes/no from the originating user ("yes, really do this").
161 Confirm,
162 /// Re-auth / second factor by the originating user (fresh token,
163 /// elevated `acr`).
164 StepUp,
165 /// Signed statement from a designated party ("I confirm I reviewed X").
166 Attestation,
167 /// Free-form clarification from the originating user.
168 Info,
169 /// Peer review of an action by a colleague.
170 Review,
171}
172
173impl ElicitKind {
174 /// The snake_case wire name (matches the serde representation). Used
175 /// by the apl-cpex bridge to pass `kind` to channel plugins as a
176 /// string, since cpex-core can't depend on this enum.
177 pub fn as_str(&self) -> &'static str {
178 match self {
179 ElicitKind::Approval => "approval",
180 ElicitKind::Confirm => "confirm",
181 ElicitKind::StepUp => "step_up",
182 ElicitKind::Attestation => "attestation",
183 ElicitKind::Info => "info",
184 ElicitKind::Review => "review",
185 }
186 }
187}
188
189/// One elicitation invocation inside `policy:` or `post_policy:` — the
190/// runtime dispatches a question to a human (approval, confirmation,
191/// step-up, …) through a channel plugin, holds a pending state across
192/// the agent's retries, validates the response, and resumes.
193///
194/// Structurally the elicitation analogue of [`DelegateStep`]: the DSL
195/// carries the verb; apl-cpex dispatches resolution to the named
196/// `ElicitationHandler` plugin (`plugin_name`, resolved exactly like
197/// `delegate(...)`). The key
198/// difference from delegation — which completes within one request — is
199/// that an elicitation spans the gap between *dispatch* (the first
200/// request that hits this step) and *resolution* (a later retry). That
201/// gap is owned by the channel (e.g. Keycloak CIBA), never by a plugin
202/// call: each of dispatch/check/validate is short and synchronous to the
203/// request it runs in. See `docs/apl-manager-approval-ciba-design.md`.
204///
205/// # First arrival vs. retry
206///
207/// On the first request that reaches this step, the runtime *dispatches*
208/// the elicitation and the phase yields a pending entry (the host emits
209/// JSON-RPC `-32120`). On a later retry carrying the elicitation id, the
210/// runtime *checks* status and, once resolved, *validates* the response
211/// against `scope` before the phase may proceed.
212///
213/// `config_override` is a free-form map for channel-specific params
214/// (e.g. CIBA `details_link`, Slack block-kit options); apl-core treats
215/// it as opaque and hands it to the plugin via the same per-call
216/// config-override pathway delegation uses.
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct ElicitStep {
219 /// Which elicitation contract applies (selects runtime validation).
220 pub kind: ElicitKind,
221
222 /// Name of the `ElicitationHandler` plugin to invoke — the routing
223 /// key, resolved `name → entry` exactly like `delegate(...)` resolves
224 /// its plugin. The first positional argument of the sugar verb (e.g.
225 /// `require_approval(manager-approver, ...)`). Which backend it speaks
226 /// (CIBA / Slack / in-band) is the plugin's own opaque config, not
227 /// something apl-core interprets.
228 pub plugin_name: String,
229
230 /// Optional channel label for audit/observability only (e.g.
231 /// `"ciba"`, `"slack"`). NOT a routing key — the framework never
232 /// dispatches on it. Surfaced into the bag as `elicitation.channel`
233 /// so the audit record can show how the human was reached. `None`
234 /// when the author doesn't declare one (a Phase 2 plugin may report
235 /// its own channel instead).
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub channel: Option<String>,
238
239 /// Who is being asked — an attribute reference resolved against the
240 /// policy bag at dispatch (e.g. `"user.manager"`, `"user.sub"`). For
241 /// CIBA this becomes `login_hint`; the resolved identity is
242 /// cross-checked against the responder at `validate()`.
243 pub from: String,
244
245 /// Canonical, human-readable description of what's being asked, with
246 /// request-arg substitution. Audited verbatim and shown to the
247 /// responder (CIBA `binding_message`) — the source of truth for
248 /// "what was approved," never an LLM summary. `None` for kinds that
249 /// carry their prompt elsewhere.
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub purpose: Option<String>,
252
253 /// APL boolean expression the runtime evaluates against the actual
254 /// request args at `validate()` to confirm the response covers what
255 /// was requested (e.g. `"args.amount <= 25000"`). This is the
256 /// args-binding layer — kept in APL because Keycloak does not support
257 /// RFC 9396 RAR. `None` for kinds without arg binding (e.g. a bare
258 /// `confirm`).
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub scope: Option<String>,
261
262 /// How long the elicitation stays valid before expiring (e.g.
263 /// `"24h"`). Surfaces as CIBA `requested_expiry`. `None` defers to
264 /// the channel plugin's configured default.
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub timeout: Option<String>,
267
268 /// Per-call config overrides for channel-specific params, layered on
269 /// the plugin's default config. Opaque to apl-core.
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub config_override: Option<serde_yaml::Value>,
272
273 /// `deny | continue` — what to do when dispatch or validation fails
274 /// (channel error, invalid response). `None` defaults to `"deny"`
275 /// (fail-closed; matches delegation/PDP step semantics).
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub on_error: Option<String>,
278
279 /// Human-readable source path (e.g.
280 /// `"route.payroll_adjust.policy[0]"`) — used in audit and
281 /// `Decision::Deny.rule_source` when the step denies.
282 pub source: String,
283}
284
285/// A PDP invocation, opaque-args style. Resolvers parse `args` based on
286/// the dialect they handle — apl-core doesn't impose a Cedar/OPA/AuthZen
287/// schema on `args`.
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289pub struct PdpCall {
290 pub dialect: PdpDialect,
291 /// Dialect-specific call arguments — typically a map for Cedar
292 /// (`action`, `resource`, …) or a string for OPA/AuthZen/NeMo
293 /// (a path or query). Resolvers parse this; apl-core treats it
294 /// as opaque.
295 pub args: serde_yaml::Value,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case")]
300#[non_exhaustive]
301pub enum PdpDialect {
302 /// Bare Cedar policy evaluation (`cpex-pdp-cedar-direct`).
303 Cedar,
304 Opa,
305 AuthZen,
306 NeMo,
307 /// CEL (Common Expression Language) evaluation — `cpex-pdp-cel`.
308 /// The `cel:` step carries an `expr:` string that must evaluate to a
309 /// boolean against the policy `AttributeBag` (exposed to CEL as nested
310 /// namespaces: `subject.id`, `delegation.depth`, `session.labels`, …).
311 /// A small, safe, non-Turing-complete predicate language — distinct
312 /// from the full PDPs (Cedar/OPA) so all can coexist on one
313 /// `PdpRouter`. The canonical route-YAML form is the block map
314 /// `cel: { expr: "..." }`; the `cel:(...)` call form is also accepted.
315 Cel,
316 #[serde(untagged)]
317 Custom(String),
318}
319
320impl PdpDialect {
321 /// Parse a YAML key prefix like `cedar`, `opa`, `authzen`, `nemo`
322 /// into the matching `PdpDialect`. Unknown dialects become `Custom`.
323 pub fn from_key(key: &str) -> Self {
324 match key {
325 "cedar" => Self::Cedar,
326 "opa" => Self::Opa,
327 "authzen" => Self::AuthZen,
328 "nemo" => Self::NeMo,
329 "cel" => Self::Cel,
330 other => Self::Custom(other.to_string()),
331 }
332 }
333}
334
335// =====================================================================
336// Resolver traits
337// =====================================================================
338
339/// External policy-decision dispatch. Implemented by Cedar, OPA HTTP
340/// clients, AuthZen clients, NeMo Guardrails — anything that can answer
341/// "given this call, allow or deny?" against a request context.
342///
343/// `apl-cpex` provides the bridge from CPEX plugins (e.g. `cedar-direct`)
344/// to this trait so the host doesn't have to know about the plugin types.
345#[async_trait]
346pub trait PdpResolver: Send + Sync {
347 /// What dialect this resolver handles. The evaluator routes PDP steps
348 /// to the resolver whose `dialect()` matches `Step::Pdp.call.dialect`.
349 fn dialect(&self) -> PdpDialect;
350
351 async fn evaluate(
352 &self,
353 call: &PdpCall,
354 bag: &crate::attributes::AttributeBag,
355 ) -> Result<PdpDecision, PdpError>;
356}
357
358/// Build a [`PdpResolver`] from a unified-config block. Implemented per
359/// PDP backend (cedar-direct, opa, …) and registered with
360/// the apl-cpex visitor so unified-config YAML can declare PDPs
361/// without the host pre-constructing them in code.
362///
363/// Hosts register a factory by handing it to apl-cpex's
364/// `AplOptions.pdp_factories`. When the visitor walks the unified
365/// config and finds a `global.apl.pdp[].kind` matching the factory's
366/// reported `kind()`, it calls `build` with the rest of that block.
367///
368/// The error type is `Box<dyn Error + Send + Sync>` to keep this trait
369/// in apl-core (which has no cpex deps). apl-cpex's visitor wraps
370/// the boxed error into `VisitorError` → `PluginError::Config` at the
371/// manager boundary.
372pub trait PdpFactory: Send + Sync {
373 /// Identifies which `kind:` in a config block this factory handles.
374 /// Convention: kebab-case matching the published PDP product name
375 /// (`"cedar-direct"`, `"opa"`, …).
376 fn kind(&self) -> &str;
377
378 /// Build a resolver from the rest of the PDP config block (everything
379 /// under the same map level as `kind`). Implementations parse their
380 /// own config shape; missing or malformed fields surface here.
381 fn build(
382 &self,
383 config: &serde_yaml::Value,
384 ) -> Result<std::sync::Arc<dyn PdpResolver>, Box<dyn std::error::Error + Send + Sync>>;
385}
386
387/// Where in the request lifecycle a plugin dispatch is happening.
388/// Threads through `PluginInvocation` so the invoker can select the
389/// right hook entry from a plugin that registered for both pre and
390/// post phases (e.g. `cmf.tool_pre_invoke` AND `cmf.tool_post_invoke`).
391///
392/// APL's four phases map to two dispatch phases:
393/// * `args:` field stages → `Pre`
394/// * `pre_invocation:` steps → `Pre`
395/// * `result:` field stages → `Post`
396/// * `post_invocation:` steps → `Post`
397///
398/// Plugins that need to discriminate `args` vs `policy` (same `Pre`
399/// from the dispatcher's perspective) inspect `PluginContext::hook_name()`
400/// inside their handler — the hook-routing layer doesn't slice phase
401/// finer than Pre/Post.
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403pub enum DispatchPhase {
404 Pre,
405 Post,
406}
407
408/// Context for one plugin invocation: tells the invoker the *intent* of
409/// the call so it can dispatch to the right CPEX hook contract.
410///
411/// `Step` is the policy / post_policy case — the invoker (apl-cpex side)
412/// already holds a typed payload reference; APL doesn't need to pass one.
413///
414/// `Field` is the pipe-chain case — APL is focused on a specific field
415/// value mid-transform and the plugin may rewrite that value via
416/// `PluginOutcome.modified_value`.
417///
418/// Both variants carry a `DispatchPhase` so the invoker can resolve the
419/// right hook entry against the cpex-core hook routing table when the
420/// plugin registered for multiple hooks.
421#[derive(Debug, Clone, Copy)]
422pub enum PluginInvocation<'a> {
423 /// Called from a `pre_invocation:` or `post_invocation:` step. The plugin operates
424 /// on whatever typed payload the invoker was bound to.
425 Step { phase: DispatchPhase },
426 /// Called inside an `args:` / `result:` pipe chain on one field.
427 Field {
428 name: &'a str,
429 value: &'a serde_json::Value,
430 phase: DispatchPhase,
431 },
432}
433
434impl<'a> PluginInvocation<'a> {
435 /// Convenience: the dispatch phase carried by this invocation.
436 pub fn phase(&self) -> DispatchPhase {
437 match self {
438 PluginInvocation::Step { phase } => *phase,
439 PluginInvocation::Field { phase, .. } => *phase,
440 }
441 }
442}
443
444/// Plugin invocation dispatch. apl-cpex wraps the CPEX `PluginManager`
445/// behind this trait so the apl-core evaluator stays free of cpex-core
446/// dependencies.
447#[async_trait]
448pub trait PluginInvoker: Send + Sync {
449 /// Invoke the named plugin against the current request context. The
450 /// `invocation` discriminates step vs pipe-chain call.
451 async fn invoke(
452 &self,
453 name: &str,
454 bag: &crate::attributes::AttributeBag,
455 invocation: PluginInvocation<'_>,
456 ) -> Result<PluginOutcome, PluginError>;
457}
458
459/// Delegation dispatch — invokes a `TokenDelegateHook` plugin to mint
460/// a downstream credential. apl-cpex implements this against
461/// `cpex_core::PluginManager::invoke_entries::<TokenDelegateHook>`.
462///
463/// The invoker holds the request-scoped `Extensions` internally
464/// (same pattern as `CmfPluginInvoker`), so the trait method doesn't
465/// need to pass them — the invoker uses its own snapshot to construct
466/// the `DelegationPayload` (inbound bearer token, subject, prior
467/// delegation chain).
468#[async_trait]
469pub trait DelegationInvoker: Send + Sync {
470 /// Run one delegation step. Returns a `DelegationOutcome` carrying
471 /// the granted permissions / audience / expiry the IdP issued; the
472 /// evaluator writes those into the bag as `delegation.granted_*`
473 /// attributes so subsequent rules in the same step list can
474 /// inspect them via `require(delegation.granted_permissions
475 /// contains "X")` etc.
476 ///
477 /// `step.config_override` is layered on top of the plugin's
478 /// default config and threaded through the standard per-call
479 /// override pathway.
480 async fn delegate(&self, step: &DelegateStep) -> Result<DelegationOutcome, DelegationError>;
481}
482
483/// What a delegation invocation returned.
484///
485/// On success, `decision` is `Allow` and the granted_* fields reflect
486/// what the IdP actually issued (which may be narrower than what the
487/// route asked for — `granted_permissions` is the source of truth for
488/// what the downstream tool will accept). The evaluator surfaces these
489/// into the bag under the `delegation.granted.*` sub-namespace plus a
490/// `delegation.granted = true` flag.
491///
492/// On `Deny`, granted_* fields are empty / `None` and the
493/// `delegation.granted` flag is not set (absent → falsy).
494#[derive(Debug, Clone)]
495pub struct DelegationOutcome {
496 pub decision: Decision,
497 /// Permissions the IdP actually granted on the minted token. Empty
498 /// when the call failed or the plugin returned no token.
499 pub granted_permissions: Vec<String>,
500 /// Audience the minted token is valid for. `None` when no token
501 /// was produced.
502 pub granted_audience: Option<String>,
503 /// Token expiry (RFC 3339 string for bag-friendly representation).
504 /// `None` when no token was produced.
505 pub granted_expires_at: Option<String>,
506}
507
508impl DelegationOutcome {
509 /// Convenience for the "deny, nothing granted" case.
510 pub fn deny(decision: Decision) -> Self {
511 Self {
512 decision,
513 granted_permissions: Vec::new(),
514 granted_audience: None,
515 granted_expires_at: None,
516 }
517 }
518}
519
520#[derive(Debug, Error)]
521pub enum DelegationError {
522 #[error("no delegation invoker available for plugin `{0}`")]
523 NotFound(String),
524
525 #[error("delegation dispatch failed: {0}")]
526 Dispatch(String),
527}
528
529/// `DelegationInvoker` impl that returns `NotFound` for every call.
530/// Useful as the default for evaluator callers that don't run any
531/// `delegate(...)` steps — they need to pass *something* implementing
532/// the trait, but the noop never actually gets invoked. Tests and
533/// hosts that haven't wired a real delegation backend pass this.
534pub struct NoopDelegationInvoker;
535
536#[async_trait]
537impl DelegationInvoker for NoopDelegationInvoker {
538 async fn delegate(&self, step: &DelegateStep) -> Result<DelegationOutcome, DelegationError> {
539 Err(DelegationError::NotFound(step.plugin_name.clone()))
540 }
541}
542
543// =====================================================================
544// Elicitation dispatch
545// =====================================================================
546
547/// Elicitation dispatch — drives a human-in-the-loop step (approval,
548/// confirmation, step-up, …) through a channel plugin. apl-cpex
549/// implements this against the named `ElicitationHandler` plugin
550/// (`step.plugin_name`, resolved `name → entry` like delegation); tests
551/// and un-wired hosts pass [`NoopElicitationInvoker`].
552///
553/// Three short, synchronous touchpoints span the human's (possibly
554/// hours-long) decision. The wait itself lives in the channel (e.g.
555/// Keycloak CIBA), never inside a trait call:
556///
557/// * [`dispatch`](ElicitationInvoker::dispatch) — once, on the first
558/// request that reaches the step: register the intent, open the
559/// backchannel, and return the id the agent echoes on retry.
560/// * [`check`](ElicitationInvoker::check) — on every retry: read the
561/// current status (pending / resolved / expired) without blocking.
562/// * [`validate`](ElicitationInvoker::validate) — once status is
563/// resolved: confirm the response is *genuine* (signature, intent
564/// binding, responder identity). The *sufficiency* check —
565/// [`ElicitStep::scope`] against the live request args — is the
566/// runtime's job, not the plugin's, because `scope` is an APL
567/// expression the plugin cannot evaluate.
568///
569/// Like [`DelegationInvoker`], the invoker holds the request-scoped
570/// `Extensions` internally, so the trait methods take only the step / id
571/// and never the request context.
572#[async_trait]
573pub trait ElicitationInvoker: Send + Sync {
574 /// First arrival. Register the intent and open the channel
575 /// backchannel for `step`, returning the correlation id plus the
576 /// pending metadata the evaluator writes into the bag
577 /// (`elicitation.id` / `.approver` / `.intent_id`). Short and
578 /// synchronous — the human's decision happens *after* this returns,
579 /// inside the channel.
580 ///
581 /// `resolved_from` is `step.from` already resolved against the request
582 /// bag by the runtime (e.g. `claim.manager` → the manager's actual
583 /// identity), or the literal `step.from` when it isn't a bag key. The
584 /// attribute vocabulary lives in the runtime, so the invoker receives
585 /// the resolved identity rather than re-resolving it — for CIBA this
586 /// becomes the `login_hint`.
587 async fn dispatch(
588 &self,
589 step: &ElicitStep,
590 resolved_from: &str,
591 ) -> Result<ElicitationDispatch, ElicitationError>;
592
593 /// Retry. Read the current status of a dispatched elicitation by
594 /// `id` without blocking — `Pending` until the human acts, then
595 /// `Resolved` (carrying approved/denied) or `Expired`. `step` is
596 /// passed (the same step that dispatched) so the invoker can resolve
597 /// which handler plugin owns this elicitation — on a retry only the
598 /// id is in the bag, but the step is still in scope.
599 async fn check(
600 &self,
601 step: &ElicitStep,
602 id: &str,
603 ) -> Result<ElicitationStatus, ElicitationError>;
604
605 /// Resolution. Verify that the resolved response is *genuine* — the
606 /// signed token validates, its intent binding matches this `id`, and
607 /// the responder is the resolved approver. Returns the verdict plus
608 /// the facts the evaluator records for audit. The runtime applies the
609 /// `scope`-over-args check separately before honoring an approval.
610 /// `step` resolves the owning handler plugin (see [`check`]).
611 ///
612 /// [`check`]: ElicitationInvoker::check
613 async fn validate(
614 &self,
615 step: &ElicitStep,
616 id: &str,
617 ) -> Result<ElicitationValidation, ElicitationError>;
618}
619
620/// What [`ElicitationInvoker::dispatch`] returns — the correlation id
621/// plus the pending metadata the evaluator surfaces into the bag
622/// (`elicitation.*`) and the host echoes in the JSON-RPC `-32120`
623/// pending entry.
624#[derive(Debug, Clone)]
625pub struct ElicitationDispatch {
626 /// Server-side id the agent echoes on retry. Keys the
627 /// `{requester, args, scope, original_request_id}` record.
628 pub id: String,
629 /// Resolved approver identity (the `from` attr resolved at dispatch,
630 /// e.g. the manager's `sub`). `None` when the channel resolves the
631 /// responder only at validation time. Surfaced as
632 /// `elicitation.approver`.
633 pub approver: Option<String>,
634 /// Registered intent id (lodging-intent binding) when the channel
635 /// supports it. Surfaced as `elicitation.intent_id`.
636 pub intent_id: Option<String>,
637 /// When the elicitation expires (RFC 3339). `None` defers to the
638 /// channel plugin's configured default.
639 pub expires_at: Option<String>,
640}
641
642/// Current state of a dispatched elicitation, read by
643/// [`ElicitationInvoker::check`] on each retry.
644#[derive(Debug, Clone, PartialEq, Eq)]
645pub enum ElicitationStatus {
646 /// The human has not responded yet — the phase stays pending and the
647 /// host re-emits `-32120`.
648 Pending,
649 /// The human responded. `outcome` carries approved/denied; the
650 /// runtime still calls `validate` before honoring an `Approved`.
651 Resolved { outcome: ElicitationOutcome },
652 /// The elicitation timed out before a response — the runtime fails
653 /// closed (subject to the step's `on_error`).
654 Expired,
655}
656
657/// The human's decision once an elicitation resolves.
658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
659pub enum ElicitationOutcome {
660 Approved,
661 Denied,
662}
663
664/// What [`ElicitationInvoker::validate`] returns — the *genuineness*
665/// verdict plus the resolved facts the runtime records for audit. The
666/// runtime layers the `scope`-over-args check on top before allowing the
667/// phase to proceed.
668#[derive(Debug, Clone)]
669pub struct ElicitationValidation {
670 /// `true` when the response is genuine: the signed token validates,
671 /// its intent binding matches this elicitation, and the responder is
672 /// the resolved approver.
673 pub valid: bool,
674 /// Who actually consented — cross-checked against the dispatch-time
675 /// approver. Recorded as `elicitation.approver`.
676 pub approver: Option<String>,
677 /// Intent id carried in the signed response, for audit
678 /// reconciliation against the registered intent.
679 pub intent_id: Option<String>,
680 /// Why validation failed, when `valid` is `false`. `None` on success.
681 pub reason: Option<String>,
682}
683
684/// The "ask again later" bundle — produced when an elicitation has been
685/// dispatched but the human hasn't responded yet. It carries everything
686/// the host needs to emit a JSON-RPC `-32120` ("request not complete,
687/// retry echoing this id") to the agent instead of forwarding the call.
688///
689/// This is the tri-state channel that lets `Decision` stay binary: a
690/// suspended phase reports `Decision::Allow` (nothing was *denied*) with a
691/// `Some(PendingElicitation)` alongside it. The host rule is one clause —
692/// **forward iff `Allow` AND `pending.is_none()`**; otherwise emit
693/// `-32120`. The agent re-sends with `elicitation.id`, the runtime takes
694/// the "id present → check, don't re-dispatch" path, and once the human
695/// resolves, the phase proceeds past the elicitation.
696///
697/// Pending **short-circuits** the phase (sequential elicitation): at most
698/// one pending per pass. Multiple concurrent pendings are deferred (would
699/// turn this into a `Vec` on `StepsEvaluation`).
700#[derive(Debug, Clone, PartialEq)]
701pub struct PendingElicitation {
702 /// Server-side id the agent echoes on retry (`elicitation.id`).
703 pub id: String,
704 /// Which `ElicitationHandler` plugin owns this elicitation.
705 pub plugin_name: String,
706 /// Resolved approver identity, when known at dispatch.
707 pub approver: Option<String>,
708 /// Registered intent id (lodging-intent binding), when the channel
709 /// supports it.
710 pub intent_id: Option<String>,
711 /// Optional channel label for the agent-facing `-32120` / audit.
712 pub channel: Option<String>,
713 /// When the elicitation expires (RFC 3339), when known.
714 pub expires_at: Option<String>,
715 /// Rule source path of the originating `Elicit` step, for audit.
716 pub source: String,
717}
718
719#[derive(Debug, Error)]
720pub enum ElicitationError {
721 #[error("no elicitation invoker available for plugin `{0}`")]
722 NotFound(String),
723
724 /// The handler failed to service an operation (dispatch / check /
725 /// validate) — a channel error, a handler deny, or a malformed
726 /// response. The message names the operation; the evaluator routes
727 /// this through the step's `on_error`.
728 #[error("elicitation handler error: {0}")]
729 Handler(String),
730}
731
732/// [`ElicitationInvoker`] impl that returns `NotFound` for every call.
733/// The default for evaluator callers that run no elicitation steps —
734/// they must pass *something* implementing the trait, but the noop never
735/// actually gets invoked. Mirrors [`NoopDelegationInvoker`]; tests and
736/// hosts that haven't wired a real channel backend pass this.
737pub struct NoopElicitationInvoker;
738
739#[async_trait]
740impl ElicitationInvoker for NoopElicitationInvoker {
741 async fn dispatch(
742 &self,
743 step: &ElicitStep,
744 _resolved_from: &str,
745 ) -> Result<ElicitationDispatch, ElicitationError> {
746 Err(ElicitationError::NotFound(step.plugin_name.clone()))
747 }
748
749 async fn check(
750 &self,
751 _step: &ElicitStep,
752 id: &str,
753 ) -> Result<ElicitationStatus, ElicitationError> {
754 Err(ElicitationError::NotFound(id.to_string()))
755 }
756
757 async fn validate(
758 &self,
759 _step: &ElicitStep,
760 id: &str,
761 ) -> Result<ElicitationValidation, ElicitationError> {
762 Err(ElicitationError::NotFound(id.to_string()))
763 }
764}
765
766/// `ElicitationInvoker` that immediately approves every elicitation:
767/// `dispatch` returns a synthetic id (echoing the requested `from` as the
768/// resolved approver), `check` reports `Resolved { Approved }` on the
769/// first pass, and `validate` returns a genuine verdict. This lets a
770/// single request flow dispatch → check → validate → allow without a real
771/// channel — for evaluator tests and offline demos.
772///
773/// NOT for production: it makes no actual approval decision. Hosts wire a
774/// real channel invoker (e.g. the apl-cpex `ElicitationHandler` bridge).
775#[derive(Default)]
776pub struct AutoApprovingElicitor;
777
778#[async_trait]
779impl ElicitationInvoker for AutoApprovingElicitor {
780 async fn dispatch(
781 &self,
782 step: &ElicitStep,
783 resolved_from: &str,
784 ) -> Result<ElicitationDispatch, ElicitationError> {
785 Ok(ElicitationDispatch {
786 id: format!("auto-{}", step.plugin_name),
787 // Echo the *resolved* approver, as a real channel would.
788 approver: Some(resolved_from.to_string()),
789 intent_id: Some("auto-intent".to_string()),
790 expires_at: None,
791 })
792 }
793
794 async fn check(
795 &self,
796 _step: &ElicitStep,
797 _id: &str,
798 ) -> Result<ElicitationStatus, ElicitationError> {
799 Ok(ElicitationStatus::Resolved {
800 outcome: ElicitationOutcome::Approved,
801 })
802 }
803
804 async fn validate(
805 &self,
806 _step: &ElicitStep,
807 _id: &str,
808 ) -> Result<ElicitationValidation, ElicitationError> {
809 Ok(ElicitationValidation {
810 valid: true,
811 // Leave approver/intent unset — the dispatch-time values
812 // already recorded in the bag stand.
813 approver: None,
814 intent_id: Some("auto-intent".to_string()),
815 reason: None,
816 })
817 }
818}
819
820// =====================================================================
821// Resolver results
822// =====================================================================
823
824/// What a PDP returned.
825#[derive(Debug, Clone, PartialEq)]
826pub struct PdpDecision {
827 pub decision: Decision,
828 /// Optional diagnostic info: matched policy IDs, error codes, etc.
829 /// Surfaces in audit logs; not used for control flow.
830 pub diagnostics: Vec<String>,
831}
832
833/// What a plugin returned.
834#[derive(Debug, Clone)]
835pub struct PluginOutcome {
836 pub decision: Decision,
837 /// Plugins may apply taint labels as a side effect. Same shape as
838 /// config-emitted taints (`Step::Taint` / `Stage::Taint`) so the
839 /// downstream evaluator can append both into a single
840 /// `Vec<TaintEvent>` without converting. Each event may carry
841 /// multiple scopes — `CmfPluginInvoker` uses single-scope
842 /// (`Session`) for v0 but future invokers and plugins that emit
843 /// directly are free to span scopes.
844 pub taints: Vec<TaintEvent>,
845 /// Pipe-context return: when a plugin runs as a stage inside an
846 /// args/result chain, it may rewrite the field value (e.g., a PII
847 /// scrubber producing a redacted string). `None` means "leave value
848 /// unchanged"; always `None` for policy / post_policy invocations.
849 pub modified_value: Option<serde_json::Value>,
850}
851
852impl PluginOutcome {
853 /// Convenience for the common "allow, no taints, no value change" case.
854 pub fn allow() -> Self {
855 Self {
856 decision: Decision::Allow,
857 taints: vec![],
858 modified_value: None,
859 }
860 }
861}
862
863// =====================================================================
864// Errors
865// =====================================================================
866
867#[derive(Debug, Error)]
868pub enum PdpError {
869 #[error("no PDP resolver registered for dialect {0:?}")]
870 NoResolver(PdpDialect),
871
872 #[error("PDP dispatch failed: {0}")]
873 Dispatch(String),
874}
875
876#[derive(Debug, Error)]
877pub enum PluginError {
878 #[error("no plugin invoker available for `{0}`")]
879 NotFound(String),
880
881 #[error("plugin dispatch failed: {0}")]
882 Dispatch(String),
883}
884
885// =====================================================================
886// Convenience
887// =====================================================================
888
889impl Step {
890 /// Wrap a `Rule` as a `Step`. Saves typing in tests and parser code.
891 pub fn rule(r: Rule) -> Self {
892 Step::Rule(r)
893 }
894
895 /// Returns true if this step is a plain rule (no async dispatch needed).
896 pub fn is_rule(&self) -> bool {
897 matches!(self, Step::Rule(_))
898 }
899}
900
901/// Bag keys the delegation step writes after a successful dispatch.
902/// Centralized here so the evaluator (writer) and policy authors
903/// (readers, via `require(delegation.granted.*)`) agree on the
904/// canonical names — typos in either place silently break the
905/// IdP-as-PDP pattern.
906///
907/// # Namespace
908///
909/// The `delegation.*` namespace at the top level carries INBOUND
910/// chain attributes (`delegation.depth`, `delegation.origin`,
911/// `delegation.chain`, ...) populated by identity resolver plugins
912/// via `IdentityPayload.delegation` + apply-to-extensions, then
913/// surfaced through apl-cmf's BagBuilder. See
914/// `docs/specs/delegation-hooks-rust-spec.md` §6.3 for that mapping.
915///
916/// The `delegation.granted.*` sub-namespace defined here is for
917/// OUTBOUND results — what came back from a `delegate(...)` step
918/// the framework just ran. Two writers (identity plugin for inbound,
919/// `delegate(...)` for outbound), distinct sub-trees, no collision.
920pub mod delegation_bag_keys {
921 /// `StringSet` — permissions actually granted by the IdP on the
922 /// minted token. May be narrower than `required_permissions`.
923 pub const GRANTED_PERMISSIONS: &str = "delegation.granted.permissions";
924 /// `String` — audience the minted token is valid for.
925 pub const GRANTED_AUDIENCE: &str = "delegation.granted.audience";
926 /// `String` — token expiry as RFC 3339.
927 pub const GRANTED_EXPIRES_AT: &str = "delegation.granted.expires_at";
928 /// `Bool` — set to `true` after a successful `delegate(...)`
929 /// step. Lets policy branch on success without inspecting the
930 /// granted_permissions set: `require(delegation.granted)`. Absent
931 /// (i.e. evaluates to false) when no delegate step has run OR
932 /// when the most recent one denied.
933 pub const GRANTED: &str = "delegation.granted";
934}
935
936/// Bag keys an elicitation step writes so downstream rules in the same
937/// phase — and the audit plugin — can read its state. Centralized here
938/// (like [`delegation_bag_keys`]) so the evaluator/invoker (writers) and
939/// policy authors (readers, via `require(elicitation.*)`) agree on the
940/// canonical names.
941///
942/// On *dispatch* the runtime writes `id` + `status = "pending"` (plus
943/// `approver` / `intent_id` when known). On *resolution* it updates
944/// `status` and sets `outcome`. A phase with a pending elicitation does
945/// not forward (see `docs/apl-manager-approval-ciba-design.md`).
946pub mod elicitation_bag_keys {
947 /// `String` — the elicitation id the agent echoes on retry. Server-side
948 /// key into `{requester, args, scope, original_request_id}`.
949 pub const ID: &str = "elicitation.id";
950 /// `String` — `pending | resolved | expired`.
951 pub const STATUS: &str = "elicitation.status";
952 /// `String` — resolved approver identity, cross-checked against `from`.
953 pub const APPROVER: &str = "elicitation.approver";
954 /// `String` — `approved | denied` once resolved.
955 pub const OUTCOME: &str = "elicitation.outcome";
956 /// `String` — registered intent id (lodging-intent binding), echoed in
957 /// the OP-signed token for `validate()` and audit reconciliation.
958 pub const INTENT_ID: &str = "elicitation.intent_id";
959 /// `String` — optional channel label (`ciba` / `slack` / …) for
960 /// audit/observability. Not a routing key.
961 pub const CHANNEL: &str = "elicitation.channel";
962 /// `String` — when the elicitation expires (RFC 3339), when the
963 /// channel reported one at dispatch.
964 pub const EXPIRES_AT: &str = "elicitation.expires_at";
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970
971 #[test]
972 fn from_key_maps_known_dialects() {
973 assert_eq!(PdpDialect::from_key("cedar"), PdpDialect::Cedar);
974 assert_eq!(PdpDialect::from_key("opa"), PdpDialect::Opa);
975 assert_eq!(PdpDialect::from_key("authzen"), PdpDialect::AuthZen);
976 assert_eq!(PdpDialect::from_key("nemo"), PdpDialect::NeMo);
977 assert_eq!(PdpDialect::from_key("cel"), PdpDialect::Cel);
978 }
979
980 #[test]
981 fn from_key_unknown_is_custom() {
982 assert_eq!(
983 PdpDialect::from_key("rego-remote"),
984 PdpDialect::Custom("rego-remote".to_string())
985 );
986 }
987
988 #[tokio::test]
989 async fn noop_elicitation_invoker_is_not_found_for_every_method() {
990 // The noop must never silently succeed — every method reports
991 // NotFound so an un-wired host fails closed rather than treating
992 // an elicitation step as approved.
993 let inv = NoopElicitationInvoker;
994 let step = ElicitStep {
995 kind: ElicitKind::Approval,
996 plugin_name: "manager-approver".to_string(),
997 channel: Some("ciba".to_string()),
998 from: "user.manager".to_string(),
999 purpose: None,
1000 scope: None,
1001 timeout: None,
1002 config_override: None,
1003 on_error: None,
1004 source: "route.test.policy[0]".to_string(),
1005 };
1006
1007 let d = inv.dispatch(&step, "alice@example.com").await;
1008 assert!(matches!(d, Err(ElicitationError::NotFound(c)) if c == "manager-approver"));
1009
1010 let c = inv.check(&step, "elic-123").await;
1011 assert!(matches!(c, Err(ElicitationError::NotFound(id)) if id == "elic-123"));
1012
1013 let v = inv.validate(&step, "elic-123").await;
1014 assert!(matches!(v, Err(ElicitationError::NotFound(id)) if id == "elic-123"));
1015 }
1016
1017 #[test]
1018 fn elicitation_status_resolved_carries_outcome() {
1019 // Resolved is distinct from its outcome — a denied resolution is
1020 // still "resolved" (the runtime stops retrying) but must not be
1021 // confused with Pending/Expired.
1022 let approved = ElicitationStatus::Resolved {
1023 outcome: ElicitationOutcome::Approved,
1024 };
1025 let denied = ElicitationStatus::Resolved {
1026 outcome: ElicitationOutcome::Denied,
1027 };
1028 assert_ne!(approved, denied);
1029 assert_ne!(approved, ElicitationStatus::Pending);
1030 assert_ne!(denied, ElicitationStatus::Expired);
1031 }
1032
1033 #[test]
1034 fn cel_dialect_serde_roundtrips_as_snake_case() {
1035 // `Cel` is a tagged variant (snake_case) — must round-trip so
1036 // compiled-route serialization (audit/cache) preserves it.
1037 let json = serde_json::to_string(&PdpDialect::Cel).unwrap();
1038 assert_eq!(json, "\"cel\"");
1039 let back: PdpDialect = serde_json::from_str(&json).unwrap();
1040 assert_eq!(back, PdpDialect::Cel);
1041 }
1042}