Skip to main content

converge_core/gates/
authorization.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Neutral flow-gate authorization contract.
5//!
6//! Converging flows should project their current state into [`FlowGateInput`]
7//! and ask a [`FlowGateAuthorizer`] for a deterministic decision. Concrete
8//! implementations may use Cedar, fixed test doubles, or another governed
9//! evaluator, but the flow runtime stays decoupled from those details.
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use converge_pack::{
15    DomainId, FactPayload, GateId, PolicyVersionId, PrincipalId, ResourceId, ResourceKind,
16};
17
18/// Action being attempted against a converging flow.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum FlowAction {
22    Propose,
23    Validate,
24    Promote,
25    Commit,
26    AdvancePhase,
27}
28
29impl FlowAction {
30    #[must_use]
31    pub const fn as_str(self) -> &'static str {
32        match self {
33            Self::Propose => "propose",
34            Self::Validate => "validate",
35            Self::Promote => "promote",
36            Self::Commit => "commit",
37            Self::AdvancePhase => "advance_phase",
38        }
39    }
40}
41
42/// Authority level granted to a flow-gate principal.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum AuthorityLevel {
46    Advisory,
47    Supervisory,
48    Participatory,
49    Sovereign,
50}
51
52impl AuthorityLevel {
53    #[must_use]
54    pub const fn as_str(self) -> &'static str {
55        match self {
56            Self::Advisory => "advisory",
57            Self::Supervisory => "supervisory",
58            Self::Participatory => "participatory",
59            Self::Sovereign => "sovereign",
60        }
61    }
62}
63
64/// Current phase of a converging flow.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum FlowPhase {
68    Intent,
69    Framing,
70    Exploration,
71    Tension,
72    Convergence,
73    Commitment,
74}
75
76impl FlowPhase {
77    #[must_use]
78    pub const fn as_str(self) -> &'static str {
79        match self {
80            Self::Intent => "intent",
81            Self::Framing => "framing",
82            Self::Exploration => "exploration",
83            Self::Tension => "tension",
84            Self::Convergence => "convergence",
85            Self::Commitment => "commitment",
86        }
87    }
88}
89
90/// Principal facts projected from the flow host or application runtime.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct FlowGatePrincipal {
94    pub id: PrincipalId,
95    pub authority: AuthorityLevel,
96    pub domains: Vec<DomainId>,
97    pub policy_version: Option<PolicyVersionId>,
98}
99
100/// Resource facts projected from the current flow state.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct FlowGateResource {
104    pub id: ResourceId,
105    pub kind: ResourceKind,
106    pub phase: FlowPhase,
107    pub gates_passed: Vec<GateId>,
108}
109
110/// Decision-relevant facts projected from the flow state.
111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct FlowGateContext {
114    pub commitment_type: Option<String>,
115    pub amount: Option<i64>,
116    pub human_approval_present: Option<bool>,
117    pub required_gates_met: Option<bool>,
118}
119
120/// Canonical input to an authorization decision for a flow gate.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct FlowGateInput {
124    pub principal: FlowGatePrincipal,
125    pub resource: FlowGateResource,
126    pub action: FlowAction,
127    pub context: FlowGateContext,
128}
129
130impl FactPayload for FlowGateInput {
131    const FAMILY: &'static str = "converge.flow_gate.input";
132    const VERSION: u16 = 1;
133}
134
135/// Neutral outcome of a flow gate authorization decision.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum FlowGateOutcome {
139    Promote,
140    Reject,
141    Escalate,
142}
143
144impl FlowGateOutcome {
145    #[must_use]
146    pub const fn is_allowed(self) -> bool {
147        matches!(self, Self::Promote)
148    }
149}
150
151/// Full gate decision with rationale and source attribution.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct FlowGateDecision {
155    pub outcome: FlowGateOutcome,
156    pub reason: Option<String>,
157    pub source: Option<String>,
158}
159
160impl FactPayload for FlowGateDecision {
161    const FAMILY: &'static str = "converge.flow_gate.decision";
162    const VERSION: u16 = 1;
163}
164
165impl FlowGateDecision {
166    #[must_use]
167    pub fn promote(reason: Option<String>, source: Option<String>) -> Self {
168        Self {
169            outcome: FlowGateOutcome::Promote,
170            reason,
171            source,
172        }
173    }
174
175    #[must_use]
176    pub fn reject(reason: Option<String>, source: Option<String>) -> Self {
177        Self {
178            outcome: FlowGateOutcome::Reject,
179            reason,
180            source,
181        }
182    }
183
184    #[must_use]
185    pub fn escalate(reason: Option<String>, source: Option<String>) -> Self {
186        Self {
187            outcome: FlowGateOutcome::Escalate,
188            reason,
189            source,
190        }
191    }
192}
193
194/// Pure error surface for flow gate authorization.
195#[derive(Debug, Error)]
196pub enum FlowGateError {
197    #[error("authorizer failed: {0}")]
198    Authorizer(String),
199    #[error("invalid flow gate input: {0}")]
200    InvalidInput(String),
201}
202
203/// Deterministic decision provider for consequential flow actions.
204pub trait FlowGateAuthorizer: Send + Sync {
205    /// Decide whether the attempted flow action should promote, reject, or escalate.
206    fn decide(&self, input: &FlowGateInput) -> Result<FlowGateDecision, FlowGateError>;
207}
208
209/// Test double: always promote.
210#[derive(Debug, Default, Clone, Copy)]
211pub struct AllowAllFlowGateAuthorizer;
212
213impl FlowGateAuthorizer for AllowAllFlowGateAuthorizer {
214    fn decide(&self, _input: &FlowGateInput) -> Result<FlowGateDecision, FlowGateError> {
215        Ok(FlowGateDecision::promote(
216            Some("allow_all test authorizer".into()),
217            Some("allow_all".into()),
218        ))
219    }
220}
221
222/// Test double: always reject.
223#[derive(Debug, Default, Clone)]
224pub struct RejectAllFlowGateAuthorizer {
225    reason: Option<String>,
226}
227
228impl RejectAllFlowGateAuthorizer {
229    #[must_use]
230    pub fn with_reason(reason: impl Into<String>) -> Self {
231        Self {
232            reason: Some(reason.into()),
233        }
234    }
235}
236
237impl FlowGateAuthorizer for RejectAllFlowGateAuthorizer {
238    fn decide(&self, _input: &FlowGateInput) -> Result<FlowGateDecision, FlowGateError> {
239        Ok(FlowGateDecision::reject(
240            self.reason
241                .clone()
242                .or_else(|| Some("reject_all test authorizer".into())),
243            Some("reject_all".into()),
244        ))
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn sample_input() -> FlowGateInput {
253        FlowGateInput {
254            principal: FlowGatePrincipal {
255                id: "agent:test".into(),
256                authority: AuthorityLevel::Supervisory,
257                domains: vec!["finance".into()],
258                policy_version: Some("v1".into()),
259            },
260            resource: FlowGateResource {
261                id: "expense:1".into(),
262                kind: "expense".into(),
263                phase: FlowPhase::Commitment,
264                gates_passed: vec!["receipt".into()],
265            },
266            action: FlowAction::Validate,
267            context: FlowGateContext {
268                commitment_type: Some("expense".into()),
269                amount: Some(100),
270                human_approval_present: Some(false),
271                required_gates_met: Some(true),
272            },
273        }
274    }
275
276    #[test]
277    fn allow_all_authorizer_promotes() {
278        let decision = AllowAllFlowGateAuthorizer
279            .decide(&sample_input())
280            .expect("allow_all should succeed");
281        assert_eq!(decision.outcome, FlowGateOutcome::Promote);
282    }
283
284    #[test]
285    fn reject_all_authorizer_rejects() {
286        let decision = RejectAllFlowGateAuthorizer::with_reason("blocked")
287            .decide(&sample_input())
288            .expect("reject_all should succeed");
289        assert_eq!(decision.outcome, FlowGateOutcome::Reject);
290        assert_eq!(decision.reason.as_deref(), Some("blocked"));
291    }
292
293    // ── FlowAction enum ──────────────────────────────────────────────────────
294
295    #[test]
296    fn flow_action_as_str_all_variants() {
297        assert_eq!(FlowAction::Propose.as_str(), "propose");
298        assert_eq!(FlowAction::Validate.as_str(), "validate");
299        assert_eq!(FlowAction::Promote.as_str(), "promote");
300        assert_eq!(FlowAction::Commit.as_str(), "commit");
301        assert_eq!(FlowAction::AdvancePhase.as_str(), "advance_phase");
302    }
303
304    #[test]
305    fn flow_action_serde_roundtrip() {
306        let actions = [
307            FlowAction::Propose,
308            FlowAction::Validate,
309            FlowAction::Promote,
310            FlowAction::Commit,
311            FlowAction::AdvancePhase,
312        ];
313        for action in actions {
314            let json = serde_json::to_string(&action).unwrap();
315            let back: FlowAction = serde_json::from_str(&json).unwrap();
316            assert_eq!(back, action);
317            assert_eq!(json, format!("\"{}\"", action.as_str()));
318        }
319    }
320
321    // ── AuthorityLevel enum ──────────────────────────────────────────────────
322
323    #[test]
324    fn authority_level_as_str_all_variants() {
325        assert_eq!(AuthorityLevel::Advisory.as_str(), "advisory");
326        assert_eq!(AuthorityLevel::Supervisory.as_str(), "supervisory");
327        assert_eq!(AuthorityLevel::Participatory.as_str(), "participatory");
328        assert_eq!(AuthorityLevel::Sovereign.as_str(), "sovereign");
329    }
330
331    #[test]
332    fn authority_level_serde_roundtrip() {
333        let levels = [
334            AuthorityLevel::Advisory,
335            AuthorityLevel::Supervisory,
336            AuthorityLevel::Participatory,
337            AuthorityLevel::Sovereign,
338        ];
339        for level in levels {
340            let json = serde_json::to_string(&level).unwrap();
341            let back: AuthorityLevel = serde_json::from_str(&json).unwrap();
342            assert_eq!(back, level);
343        }
344    }
345
346    // ── FlowPhase enum ───────────────────────────────────────────────────────
347
348    #[test]
349    fn flow_phase_as_str_all_variants() {
350        assert_eq!(FlowPhase::Intent.as_str(), "intent");
351        assert_eq!(FlowPhase::Framing.as_str(), "framing");
352        assert_eq!(FlowPhase::Exploration.as_str(), "exploration");
353        assert_eq!(FlowPhase::Tension.as_str(), "tension");
354        assert_eq!(FlowPhase::Convergence.as_str(), "convergence");
355        assert_eq!(FlowPhase::Commitment.as_str(), "commitment");
356    }
357
358    #[test]
359    fn flow_phase_serde_roundtrip() {
360        let phases = [
361            FlowPhase::Intent,
362            FlowPhase::Framing,
363            FlowPhase::Exploration,
364            FlowPhase::Tension,
365            FlowPhase::Convergence,
366            FlowPhase::Commitment,
367        ];
368        for phase in phases {
369            let json = serde_json::to_string(&phase).unwrap();
370            let back: FlowPhase = serde_json::from_str(&json).unwrap();
371            assert_eq!(back, phase);
372        }
373    }
374
375    // ── FlowGateOutcome ──────────────────────────────────────────────────────
376
377    #[test]
378    fn outcome_is_allowed() {
379        assert!(FlowGateOutcome::Promote.is_allowed());
380        assert!(!FlowGateOutcome::Reject.is_allowed());
381        assert!(!FlowGateOutcome::Escalate.is_allowed());
382    }
383
384    // ── FlowGateDecision factories ───────────────────────────────────────────
385
386    #[test]
387    fn decision_promote_factory() {
388        let d = FlowGateDecision::promote(Some("approved".into()), Some("policy:1".into()));
389        assert_eq!(d.outcome, FlowGateOutcome::Promote);
390        assert_eq!(d.reason.as_deref(), Some("approved"));
391        assert_eq!(d.source.as_deref(), Some("policy:1"));
392    }
393
394    #[test]
395    fn decision_reject_factory() {
396        let d = FlowGateDecision::reject(None, None);
397        assert_eq!(d.outcome, FlowGateOutcome::Reject);
398        assert!(d.reason.is_none());
399        assert!(d.source.is_none());
400    }
401
402    #[test]
403    fn decision_escalate_factory() {
404        let d = FlowGateDecision::escalate(Some("needs human".into()), Some("hitl".into()));
405        assert_eq!(d.outcome, FlowGateOutcome::Escalate);
406        assert_eq!(d.reason.as_deref(), Some("needs human"));
407    }
408
409    // ── FlowGateError ────────────────────────────────────────────────────────
410
411    #[test]
412    fn flow_gate_error_display() {
413        let e = FlowGateError::Authorizer("connection reset".into());
414        assert!(e.to_string().contains("connection reset"));
415
416        let e = FlowGateError::InvalidInput("missing principal".into());
417        assert!(e.to_string().contains("missing principal"));
418    }
419
420    #[test]
421    fn flow_gate_error_is_std_error() {
422        let e: Box<dyn std::error::Error> = Box::new(FlowGateError::Authorizer("test".into()));
423        assert!(e.to_string().contains("test"));
424    }
425
426    // ── FlowGateInput serde ──────────────────────────────────────────────────
427
428    #[test]
429    fn flow_gate_input_serde_roundtrip() {
430        let input = sample_input();
431        let json = serde_json::to_string(&input).unwrap();
432        let back: FlowGateInput = serde_json::from_str(&json).unwrap();
433        assert_eq!(back.action, FlowAction::Validate);
434        assert_eq!(back.principal.authority, AuthorityLevel::Supervisory);
435        assert_eq!(back.resource.phase, FlowPhase::Commitment);
436        assert_eq!(back.context.amount, Some(100));
437    }
438
439    // ── RejectAllFlowGateAuthorizer default reason ───────────────────────────
440
441    #[test]
442    fn reject_all_default_reason() {
443        let authorizer = RejectAllFlowGateAuthorizer::default();
444        let decision = authorizer.decide(&sample_input()).unwrap();
445        assert_eq!(decision.outcome, FlowGateOutcome::Reject);
446        assert!(decision.reason.as_deref().unwrap().contains("reject_all"));
447    }
448}