1pub mod admission;
10pub mod decomposition;
11pub mod resolution;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct IntentPacket {
27 pub id: Uuid,
28 pub outcome: String,
29 pub context: serde_json::Value,
30 pub constraints: Vec<String>,
31 pub authority: Vec<String>,
32 pub forbidden: Vec<ForbiddenAction>,
33 pub reversibility: Reversibility,
34 pub expires: DateTime<Utc>,
35 pub expiry_action: ExpiryAction,
36}
37
38impl IntentPacket {
39 pub fn new(outcome: impl Into<String>, expires: DateTime<Utc>) -> Self {
40 Self {
41 id: Uuid::new_v4(),
42 outcome: outcome.into(),
43 context: serde_json::Value::Null,
44 constraints: Vec::new(),
45 authority: Vec::new(),
46 forbidden: Vec::new(),
47 reversibility: Reversibility::Reversible,
48 expires,
49 expiry_action: ExpiryAction::Halt,
50 }
51 }
52
53 pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
54 now >= self.expires
55 }
56
57 pub fn with_context(mut self, ctx: serde_json::Value) -> Self {
58 self.context = ctx;
59 self
60 }
61
62 pub fn with_authority(mut self, authority: Vec<String>) -> Self {
63 self.authority = authority;
64 self
65 }
66
67 pub fn with_reversibility(mut self, r: Reversibility) -> Self {
68 self.reversibility = r;
69 self
70 }
71
72 pub fn with_expiry_action(mut self, action: ExpiryAction) -> Self {
73 self.expiry_action = action;
74 self
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum Reversibility {
83 Reversible,
84 Partial,
85 Irreversible,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ForbiddenAction {
92 pub action: String,
93 pub reason: String,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum ExpiryAction {
101 Halt,
102 Escalate,
103 CompleteAndHalt,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct AdmissionResult {
110 pub feasible: bool,
111 pub dimensions: Vec<FeasibilityAssessment>,
112 pub rejection_reason: Option<String>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct FeasibilityAssessment {
117 pub dimension: FeasibilityDimension,
118 pub kind: FeasibilityKind,
119 pub reason: String,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum FeasibilityDimension {
125 Capability,
126 Context,
127 Resources,
128 Authority,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum FeasibilityKind {
134 Feasible,
135 FeasibleWithConstraints,
136 Uncertain,
137 Infeasible,
138}
139
140pub trait AdmissionController: Send + Sync {
141 fn evaluate(&self, intent: &IntentPacket) -> AdmissionResult;
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct IntentNode {
150 pub id: Uuid,
151 pub intent: IntentPacket,
152 pub children: Vec<IntentNode>,
153}
154
155impl IntentNode {
156 pub fn leaf(intent: IntentPacket) -> Self {
157 Self {
158 id: Uuid::new_v4(),
159 intent,
160 children: Vec::new(),
161 }
162 }
163
164 pub fn is_leaf(&self) -> bool {
165 self.children.is_empty()
166 }
167}
168
169#[derive(Debug, thiserror::Error)]
172pub enum IntentError {
173 #[error("intent expired at {0}")]
174 Expired(DateTime<Utc>),
175 #[error("intent forbidden by rule: {0}")]
176 Forbidden(String),
177 #[error("intent infeasible: {0}")]
178 Infeasible(String),
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use chrono::Duration;
185
186 fn future() -> DateTime<Utc> {
187 Utc::now() + Duration::hours(1)
188 }
189
190 fn past() -> DateTime<Utc> {
191 Utc::now() - Duration::seconds(10)
192 }
193
194 #[test]
195 fn new_sets_defaults() {
196 let intent = IntentPacket::new("ship q3", future());
197 assert_eq!(intent.outcome, "ship q3");
198 assert_eq!(intent.context, serde_json::Value::Null);
199 assert!(intent.constraints.is_empty());
200 assert!(intent.authority.is_empty());
201 assert!(intent.forbidden.is_empty());
202 assert_eq!(intent.reversibility, Reversibility::Reversible);
203 assert_eq!(intent.expiry_action, ExpiryAction::Halt);
204 }
205
206 #[test]
207 fn new_generates_unique_ids() {
208 let a = IntentPacket::new("a", future());
209 let b = IntentPacket::new("b", future());
210 assert_ne!(a.id, b.id);
211 }
212
213 #[test]
214 fn is_expired_past() {
215 let intent = IntentPacket::new("late", past());
216 assert!(intent.is_expired(Utc::now()));
217 }
218
219 #[test]
220 fn is_expired_future() {
221 let intent = IntentPacket::new("on time", future());
222 assert!(!intent.is_expired(Utc::now()));
223 }
224
225 #[test]
226 fn is_expired_exact_boundary() {
227 let now = Utc::now();
228 let intent = IntentPacket::new("boundary", now);
229 assert!(intent.is_expired(now));
230 }
231
232 #[test]
233 fn with_context() {
234 let intent =
235 IntentPacket::new("ctx", future()).with_context(serde_json::json!({"key": "value"}));
236 assert_eq!(intent.context["key"], "value");
237 }
238
239 #[test]
240 fn with_authority() {
241 let intent = IntentPacket::new("auth", future())
242 .with_authority(vec!["admin".into(), "finance".into()]);
243 assert_eq!(intent.authority.len(), 2);
244 assert_eq!(intent.authority[0], "admin");
245 }
246
247 #[test]
248 fn with_reversibility() {
249 let intent =
250 IntentPacket::new("rev", future()).with_reversibility(Reversibility::Irreversible);
251 assert_eq!(intent.reversibility, Reversibility::Irreversible);
252 }
253
254 #[test]
255 fn with_expiry_action() {
256 let intent = IntentPacket::new("exp", future()).with_expiry_action(ExpiryAction::Escalate);
257 assert_eq!(intent.expiry_action, ExpiryAction::Escalate);
258 }
259
260 #[test]
261 fn builder_chain() {
262 let intent = IntentPacket::new("full", future())
263 .with_context(serde_json::json!(null))
264 .with_authority(vec![])
265 .with_reversibility(Reversibility::Partial)
266 .with_expiry_action(ExpiryAction::CompleteAndHalt);
267 assert_eq!(intent.reversibility, Reversibility::Partial);
268 assert_eq!(intent.expiry_action, ExpiryAction::CompleteAndHalt);
269 }
270
271 #[test]
272 fn serde_roundtrip() {
273 let intent = IntentPacket::new("roundtrip", future())
274 .with_context(serde_json::json!({"n": 42}))
275 .with_authority(vec!["ops".into()])
276 .with_reversibility(Reversibility::Partial)
277 .with_expiry_action(ExpiryAction::Escalate);
278
279 let json = serde_json::to_string(&intent).unwrap();
280 let back: IntentPacket = serde_json::from_str(&json).unwrap();
281 assert_eq!(back.id, intent.id);
282 assert_eq!(back.outcome, "roundtrip");
283 assert_eq!(back.context["n"], 42);
284 assert_eq!(back.authority, vec!["ops"]);
285 assert_eq!(back.reversibility, Reversibility::Partial);
286 assert_eq!(back.expiry_action, ExpiryAction::Escalate);
287 }
288
289 #[test]
290 fn serde_with_forbidden() {
291 let mut intent = IntentPacket::new("forbidden", future());
292 intent.forbidden.push(ForbiddenAction {
293 action: "delete_prod".into(),
294 reason: "destructive".into(),
295 });
296
297 let json = serde_json::to_string(&intent).unwrap();
298 let back: IntentPacket = serde_json::from_str(&json).unwrap();
299 assert_eq!(back.forbidden.len(), 1);
300 assert_eq!(back.forbidden[0].action, "delete_prod");
301 }
302
303 #[test]
304 fn reversibility_all_variants_serde() {
305 for v in [
306 Reversibility::Reversible,
307 Reversibility::Partial,
308 Reversibility::Irreversible,
309 ] {
310 let json = serde_json::to_string(&v).unwrap();
311 let back: Reversibility = serde_json::from_str(&json).unwrap();
312 assert_eq!(v, back);
313 }
314 }
315
316 #[test]
317 fn reversibility_snake_case() {
318 assert_eq!(
319 serde_json::to_string(&Reversibility::Reversible).unwrap(),
320 "\"reversible\""
321 );
322 assert_eq!(
323 serde_json::to_string(&Reversibility::Partial).unwrap(),
324 "\"partial\""
325 );
326 assert_eq!(
327 serde_json::to_string(&Reversibility::Irreversible).unwrap(),
328 "\"irreversible\""
329 );
330 }
331
332 #[test]
333 fn expiry_action_all_variants_serde() {
334 for v in [
335 ExpiryAction::Halt,
336 ExpiryAction::Escalate,
337 ExpiryAction::CompleteAndHalt,
338 ] {
339 let json = serde_json::to_string(&v).unwrap();
340 let back: ExpiryAction = serde_json::from_str(&json).unwrap();
341 assert_eq!(v, back);
342 }
343 }
344
345 #[test]
346 fn expiry_action_snake_case() {
347 assert_eq!(
348 serde_json::to_string(&ExpiryAction::Halt).unwrap(),
349 "\"halt\""
350 );
351 assert_eq!(
352 serde_json::to_string(&ExpiryAction::Escalate).unwrap(),
353 "\"escalate\""
354 );
355 assert_eq!(
356 serde_json::to_string(&ExpiryAction::CompleteAndHalt).unwrap(),
357 "\"complete_and_halt\""
358 );
359 }
360
361 #[test]
362 fn feasibility_dimension_all_variants_serde() {
363 for v in [
364 FeasibilityDimension::Capability,
365 FeasibilityDimension::Context,
366 FeasibilityDimension::Resources,
367 FeasibilityDimension::Authority,
368 ] {
369 let json = serde_json::to_string(&v).unwrap();
370 let back: FeasibilityDimension = serde_json::from_str(&json).unwrap();
371 assert_eq!(v, back);
372 }
373 }
374
375 #[test]
376 fn feasibility_kind_all_variants_serde() {
377 for v in [
378 FeasibilityKind::Feasible,
379 FeasibilityKind::FeasibleWithConstraints,
380 FeasibilityKind::Uncertain,
381 FeasibilityKind::Infeasible,
382 ] {
383 let json = serde_json::to_string(&v).unwrap();
384 let back: FeasibilityKind = serde_json::from_str(&json).unwrap();
385 assert_eq!(v, back);
386 }
387 }
388
389 #[test]
390 fn feasibility_kind_snake_case() {
391 assert_eq!(
392 serde_json::to_string(&FeasibilityKind::FeasibleWithConstraints).unwrap(),
393 "\"feasible_with_constraints\""
394 );
395 }
396
397 #[test]
398 fn admission_result_serde_roundtrip() {
399 let result = AdmissionResult {
400 feasible: false,
401 dimensions: vec![FeasibilityAssessment {
402 dimension: FeasibilityDimension::Authority,
403 kind: FeasibilityKind::Infeasible,
404 reason: "no authority".into(),
405 }],
406 rejection_reason: Some("not authorized".into()),
407 };
408 let json = serde_json::to_string(&result).unwrap();
409 let back: AdmissionResult = serde_json::from_str(&json).unwrap();
410 assert!(!back.feasible);
411 assert_eq!(back.dimensions.len(), 1);
412 assert_eq!(back.rejection_reason.as_deref(), Some("not authorized"));
413 }
414
415 #[test]
416 fn forbidden_action_serde_roundtrip() {
417 let fa = ForbiddenAction {
418 action: "fire_all".into(),
419 reason: "HR policy".into(),
420 };
421 let json = serde_json::to_string(&fa).unwrap();
422 let back: ForbiddenAction = serde_json::from_str(&json).unwrap();
423 assert_eq!(back.action, "fire_all");
424 assert_eq!(back.reason, "HR policy");
425 }
426
427 #[test]
428 fn intent_node_leaf_is_leaf() {
429 let node = IntentNode::leaf(IntentPacket::new("leaf", future()));
430 assert!(node.is_leaf());
431 }
432
433 #[test]
434 fn intent_node_with_children_not_leaf() {
435 let child = IntentNode::leaf(IntentPacket::new("child", future()));
436 let parent = IntentNode {
437 id: Uuid::new_v4(),
438 intent: IntentPacket::new("parent", future()),
439 children: vec![child],
440 };
441 assert!(!parent.is_leaf());
442 }
443
444 #[test]
445 fn intent_error_display() {
446 let err = IntentError::Forbidden("no access".into());
447 assert_eq!(err.to_string(), "intent forbidden by rule: no access");
448
449 let err = IntentError::Infeasible("not enough resources".into());
450 assert_eq!(err.to_string(), "intent infeasible: not enough resources");
451 }
452
453 #[test]
454 fn intent_error_expired_display() {
455 let t = Utc::now();
456 let err = IntentError::Expired(t);
457 assert!(err.to_string().starts_with("intent expired at "));
458 }
459
460 #[test]
461 fn intent_packet_accepts_string_and_str() {
462 let from_str = IntentPacket::new("literal", future());
463 let from_string = IntentPacket::new(String::from("owned"), future());
464 assert_eq!(from_str.outcome, "literal");
465 assert_eq!(from_string.outcome, "owned");
466 }
467
468 #[test]
469 fn intent_packet_empty_outcome() {
470 let intent = IntentPacket::new("", future());
471 assert_eq!(intent.outcome, "");
472 }
473
474 #[test]
475 fn intent_packet_with_constraints() {
476 let mut intent = IntentPacket::new("constrained", future());
477 intent.constraints = vec!["budget < 10k".into(), "no external vendors".into()];
478 let json = serde_json::to_string(&intent).unwrap();
479 let back: IntentPacket = serde_json::from_str(&json).unwrap();
480 assert_eq!(back.constraints.len(), 2);
481 }
482}