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