Skip to main content

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}