1use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use converge_pack::{
15 DomainId, FactPayload, GateId, PolicyVersionId, PrincipalId, ResourceId, ResourceKind,
16};
17
18#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
203pub trait FlowGateAuthorizer: Send + Sync {
205 fn decide(&self, input: &FlowGateInput) -> Result<FlowGateDecision, FlowGateError>;
207}
208
209#[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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}