1pub mod admission;
10pub mod convergence;
11pub mod decomposition;
12pub mod graded_admission;
13pub mod problem;
14pub mod resolution;
15
16pub use convergence::{ConvergenceCriteria, ConvergenceSignal};
17pub use graded_admission::{DimensionRulebook, GradedAdmissionController};
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct IntentPacket {
33 pub id: Uuid,
34 pub outcome: String,
35 pub context: serde_json::Value,
36 pub constraints: Vec<String>,
37 pub authority: Vec<String>,
38 pub forbidden: Vec<ForbiddenAction>,
39 pub reversibility: Reversibility,
40 pub expires: DateTime<Utc>,
41 pub expiry_action: ExpiryAction,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub convergence: Option<ConvergenceCriteria>,
44}
45
46impl IntentPacket {
47 pub fn new(outcome: impl Into<String>, expires: DateTime<Utc>) -> Self {
48 Self {
49 id: Uuid::new_v4(),
50 outcome: outcome.into(),
51 context: serde_json::Value::Null,
52 constraints: Vec::new(),
53 authority: Vec::new(),
54 forbidden: Vec::new(),
55 reversibility: Reversibility::Reversible,
56 expires,
57 expiry_action: ExpiryAction::Halt,
58 convergence: None,
59 }
60 }
61
62 pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
63 now >= self.expires
64 }
65
66 pub fn with_context(mut self, ctx: serde_json::Value) -> Self {
67 self.context = ctx;
68 self
69 }
70
71 pub fn with_authority(mut self, authority: Vec<String>) -> Self {
72 self.authority = authority;
73 self
74 }
75
76 pub fn with_reversibility(mut self, r: Reversibility) -> Self {
77 self.reversibility = r;
78 self
79 }
80
81 pub fn with_expiry_action(mut self, action: ExpiryAction) -> Self {
82 self.expiry_action = action;
83 self
84 }
85
86 pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
87 self.convergence = Some(criteria);
88 self
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum Reversibility {
97 Reversible,
98 Partial,
99 Irreversible,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ForbiddenAction {
106 pub action: String,
107 pub reason: String,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum ExpiryAction {
115 Halt,
116 Escalate,
117 CompleteAndHalt,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct AdmissionResult {
124 pub feasible: bool,
125 pub dimensions: Vec<FeasibilityAssessment>,
126 pub rejection_reason: Option<String>,
127}
128
129impl AdmissionResult {
130 #[must_use]
134 pub fn from_dimensions(dimensions: Vec<FeasibilityAssessment>) -> Self {
135 let infeasible_reasons: Vec<String> = dimensions
136 .iter()
137 .filter(|d| d.kind == FeasibilityKind::Infeasible)
138 .map(|d| d.reason.clone())
139 .collect();
140 let feasible = infeasible_reasons.is_empty();
141 let rejection_reason = if feasible {
142 None
143 } else {
144 Some(infeasible_reasons.join("; "))
145 };
146 Self {
147 feasible,
148 dimensions,
149 rejection_reason,
150 }
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct FeasibilityAssessment {
156 pub dimension: FeasibilityDimension,
157 pub kind: FeasibilityKind,
158 pub reason: String,
159}
160
161impl FeasibilityAssessment {
162 #[must_use]
163 pub fn feasible(dimension: FeasibilityDimension, reason: impl Into<String>) -> Self {
164 Self {
165 dimension,
166 kind: FeasibilityKind::Feasible,
167 reason: reason.into(),
168 }
169 }
170
171 #[must_use]
172 pub fn infeasible(dimension: FeasibilityDimension, reason: impl Into<String>) -> Self {
173 Self {
174 dimension,
175 kind: FeasibilityKind::Infeasible,
176 reason: reason.into(),
177 }
178 }
179
180 #[must_use]
181 pub fn uncertain(dimension: FeasibilityDimension, reason: impl Into<String>) -> Self {
182 Self {
183 dimension,
184 kind: FeasibilityKind::Uncertain,
185 reason: reason.into(),
186 }
187 }
188
189 #[must_use]
190 pub fn with_constraints(dimension: FeasibilityDimension, reason: impl Into<String>) -> Self {
191 Self {
192 dimension,
193 kind: FeasibilityKind::FeasibleWithConstraints,
194 reason: reason.into(),
195 }
196 }
197
198 #[must_use]
199 pub fn is_blocking(&self) -> bool {
200 self.kind == FeasibilityKind::Infeasible
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum FeasibilityDimension {
207 Capability,
208 Context,
209 Resources,
210 Authority,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum FeasibilityKind {
216 Feasible,
217 FeasibleWithConstraints,
218 Uncertain,
219 Infeasible,
220}
221
222pub trait AdmissionController: Send + Sync {
223 fn evaluate(&self, intent: &IntentPacket) -> AdmissionResult;
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct IntentNode {
232 pub id: Uuid,
233 pub intent: IntentPacket,
234 pub children: Vec<IntentNode>,
235}
236
237impl IntentNode {
238 pub fn leaf(intent: IntentPacket) -> Self {
239 Self {
240 id: Uuid::new_v4(),
241 intent,
242 children: Vec::new(),
243 }
244 }
245
246 pub fn is_leaf(&self) -> bool {
247 self.children.is_empty()
248 }
249}
250
251#[derive(Debug, thiserror::Error)]
254pub enum IntentError {
255 #[error("intent expired at {0}")]
256 Expired(DateTime<Utc>),
257 #[error("intent forbidden by rule: {0}")]
258 Forbidden(String),
259 #[error("intent infeasible: {0}")]
260 Infeasible(String),
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use chrono::Duration;
267
268 fn future() -> DateTime<Utc> {
269 Utc::now() + Duration::hours(1)
270 }
271
272 fn past() -> DateTime<Utc> {
273 Utc::now() - Duration::seconds(10)
274 }
275
276 #[test]
277 fn new_sets_defaults() {
278 let intent = IntentPacket::new("ship q3", future());
279 assert_eq!(intent.outcome, "ship q3");
280 assert_eq!(intent.context, serde_json::Value::Null);
281 assert!(intent.constraints.is_empty());
282 assert!(intent.authority.is_empty());
283 assert!(intent.forbidden.is_empty());
284 assert_eq!(intent.reversibility, Reversibility::Reversible);
285 assert_eq!(intent.expiry_action, ExpiryAction::Halt);
286 assert_eq!(intent.convergence, None);
287 }
288
289 #[test]
290 fn new_generates_unique_ids() {
291 let a = IntentPacket::new("a", future());
292 let b = IntentPacket::new("b", future());
293 assert_ne!(a.id, b.id);
294 }
295
296 #[test]
297 fn is_expired_past() {
298 let intent = IntentPacket::new("late", past());
299 assert!(intent.is_expired(Utc::now()));
300 }
301
302 #[test]
303 fn is_expired_future() {
304 let intent = IntentPacket::new("on time", future());
305 assert!(!intent.is_expired(Utc::now()));
306 }
307
308 #[test]
309 fn is_expired_exact_boundary() {
310 let now = Utc::now();
311 let intent = IntentPacket::new("boundary", now);
312 assert!(intent.is_expired(now));
313 }
314
315 #[test]
316 fn with_context() {
317 let intent =
318 IntentPacket::new("ctx", future()).with_context(serde_json::json!({"key": "value"}));
319 assert_eq!(intent.context["key"], "value");
320 }
321
322 #[test]
323 fn with_authority() {
324 let intent = IntentPacket::new("auth", future())
325 .with_authority(vec!["admin".into(), "finance".into()]);
326 assert_eq!(intent.authority.len(), 2);
327 assert_eq!(intent.authority[0], "admin");
328 }
329
330 #[test]
331 fn with_reversibility() {
332 let intent =
333 IntentPacket::new("rev", future()).with_reversibility(Reversibility::Irreversible);
334 assert_eq!(intent.reversibility, Reversibility::Irreversible);
335 }
336
337 #[test]
338 fn with_expiry_action() {
339 let intent = IntentPacket::new("exp", future()).with_expiry_action(ExpiryAction::Escalate);
340 assert_eq!(intent.expiry_action, ExpiryAction::Escalate);
341 }
342
343 #[test]
344 fn with_convergence_criteria() {
345 let intent = IntentPacket::new("conv", future())
346 .with_convergence_criteria(ConvergenceCriteria::MaxRounds { rounds: 4 });
347 assert_eq!(
348 intent.convergence,
349 Some(ConvergenceCriteria::MaxRounds { rounds: 4 })
350 );
351 }
352
353 #[test]
354 fn builder_chain() {
355 let intent = IntentPacket::new("full", future())
356 .with_context(serde_json::json!(null))
357 .with_authority(vec![])
358 .with_reversibility(Reversibility::Partial)
359 .with_expiry_action(ExpiryAction::CompleteAndHalt);
360 assert_eq!(intent.reversibility, Reversibility::Partial);
361 assert_eq!(intent.expiry_action, ExpiryAction::CompleteAndHalt);
362 }
363
364 #[test]
365 fn serde_roundtrip() {
366 let intent = IntentPacket::new("roundtrip", future())
367 .with_context(serde_json::json!({"n": 42}))
368 .with_authority(vec!["ops".into()])
369 .with_reversibility(Reversibility::Partial)
370 .with_expiry_action(ExpiryAction::Escalate);
371
372 let json = serde_json::to_string(&intent).unwrap();
373 let back: IntentPacket = serde_json::from_str(&json).unwrap();
374 assert_eq!(back.id, intent.id);
375 assert_eq!(back.outcome, "roundtrip");
376 assert_eq!(back.context["n"], 42);
377 assert_eq!(back.authority, vec!["ops"]);
378 assert_eq!(back.reversibility, Reversibility::Partial);
379 assert_eq!(back.expiry_action, ExpiryAction::Escalate);
380 assert_eq!(back.convergence, None);
381 }
382
383 #[test]
384 fn convergence_criteria_roundtrip_on_intent_packet() {
385 let intent = IntentPacket::new("roundtrip convergence", future())
386 .with_convergence_criteria(ConvergenceCriteria::ConsensusAmongMembers);
387
388 let json = serde_json::to_string(&intent).unwrap();
389 assert!(json.contains("consensus_among_members"));
390
391 let back: IntentPacket = serde_json::from_str(&json).unwrap();
392 assert_eq!(
393 back.convergence,
394 Some(ConvergenceCriteria::ConsensusAmongMembers)
395 );
396 }
397
398 #[test]
399 fn older_intent_json_defaults_to_no_convergence_criteria() {
400 let id = Uuid::new_v4();
401 let expires = future().to_rfc3339();
402 let json = format!(
403 r#"{{
404 "id": "{id}",
405 "outcome": "old packet",
406 "context": null,
407 "constraints": [],
408 "authority": [],
409 "forbidden": [],
410 "reversibility": "reversible",
411 "expires": "{expires}",
412 "expiry_action": "halt"
413 }}"#
414 );
415
416 let back: IntentPacket = serde_json::from_str(&json).unwrap();
417 assert_eq!(back.convergence, None);
418 }
419
420 #[test]
421 fn serde_with_forbidden() {
422 let mut intent = IntentPacket::new("forbidden", future());
423 intent.forbidden.push(ForbiddenAction {
424 action: "delete_prod".into(),
425 reason: "destructive".into(),
426 });
427
428 let json = serde_json::to_string(&intent).unwrap();
429 let back: IntentPacket = serde_json::from_str(&json).unwrap();
430 assert_eq!(back.forbidden.len(), 1);
431 assert_eq!(back.forbidden[0].action, "delete_prod");
432 }
433
434 #[test]
435 fn reversibility_all_variants_serde() {
436 for v in [
437 Reversibility::Reversible,
438 Reversibility::Partial,
439 Reversibility::Irreversible,
440 ] {
441 let json = serde_json::to_string(&v).unwrap();
442 let back: Reversibility = serde_json::from_str(&json).unwrap();
443 assert_eq!(v, back);
444 }
445 }
446
447 #[test]
448 fn reversibility_snake_case() {
449 assert_eq!(
450 serde_json::to_string(&Reversibility::Reversible).unwrap(),
451 "\"reversible\""
452 );
453 assert_eq!(
454 serde_json::to_string(&Reversibility::Partial).unwrap(),
455 "\"partial\""
456 );
457 assert_eq!(
458 serde_json::to_string(&Reversibility::Irreversible).unwrap(),
459 "\"irreversible\""
460 );
461 }
462
463 #[test]
464 fn expiry_action_all_variants_serde() {
465 for v in [
466 ExpiryAction::Halt,
467 ExpiryAction::Escalate,
468 ExpiryAction::CompleteAndHalt,
469 ] {
470 let json = serde_json::to_string(&v).unwrap();
471 let back: ExpiryAction = serde_json::from_str(&json).unwrap();
472 assert_eq!(v, back);
473 }
474 }
475
476 #[test]
477 fn expiry_action_snake_case() {
478 assert_eq!(
479 serde_json::to_string(&ExpiryAction::Halt).unwrap(),
480 "\"halt\""
481 );
482 assert_eq!(
483 serde_json::to_string(&ExpiryAction::Escalate).unwrap(),
484 "\"escalate\""
485 );
486 assert_eq!(
487 serde_json::to_string(&ExpiryAction::CompleteAndHalt).unwrap(),
488 "\"complete_and_halt\""
489 );
490 }
491
492 #[test]
493 fn feasibility_dimension_all_variants_serde() {
494 for v in [
495 FeasibilityDimension::Capability,
496 FeasibilityDimension::Context,
497 FeasibilityDimension::Resources,
498 FeasibilityDimension::Authority,
499 ] {
500 let json = serde_json::to_string(&v).unwrap();
501 let back: FeasibilityDimension = serde_json::from_str(&json).unwrap();
502 assert_eq!(v, back);
503 }
504 }
505
506 #[test]
507 fn feasibility_kind_all_variants_serde() {
508 for v in [
509 FeasibilityKind::Feasible,
510 FeasibilityKind::FeasibleWithConstraints,
511 FeasibilityKind::Uncertain,
512 FeasibilityKind::Infeasible,
513 ] {
514 let json = serde_json::to_string(&v).unwrap();
515 let back: FeasibilityKind = serde_json::from_str(&json).unwrap();
516 assert_eq!(v, back);
517 }
518 }
519
520 #[test]
521 fn feasibility_kind_snake_case() {
522 assert_eq!(
523 serde_json::to_string(&FeasibilityKind::FeasibleWithConstraints).unwrap(),
524 "\"feasible_with_constraints\""
525 );
526 }
527
528 #[test]
529 fn admission_result_serde_roundtrip() {
530 let result = AdmissionResult {
531 feasible: false,
532 dimensions: vec![FeasibilityAssessment {
533 dimension: FeasibilityDimension::Authority,
534 kind: FeasibilityKind::Infeasible,
535 reason: "no authority".into(),
536 }],
537 rejection_reason: Some("not authorized".into()),
538 };
539 let json = serde_json::to_string(&result).unwrap();
540 let back: AdmissionResult = serde_json::from_str(&json).unwrap();
541 assert!(!back.feasible);
542 assert_eq!(back.dimensions.len(), 1);
543 assert_eq!(back.rejection_reason.as_deref(), Some("not authorized"));
544 }
545
546 #[test]
547 fn feasibility_constructors_set_kind() {
548 let f = FeasibilityAssessment::feasible(FeasibilityDimension::Capability, "ok");
549 assert_eq!(f.kind, FeasibilityKind::Feasible);
550 assert_eq!(f.reason, "ok");
551
552 let i = FeasibilityAssessment::infeasible(FeasibilityDimension::Context, "missing");
553 assert_eq!(i.kind, FeasibilityKind::Infeasible);
554 assert!(i.is_blocking());
555
556 let u = FeasibilityAssessment::uncertain(FeasibilityDimension::Authority, "unclear");
557 assert_eq!(u.kind, FeasibilityKind::Uncertain);
558 assert!(!u.is_blocking());
559
560 let c = FeasibilityAssessment::with_constraints(FeasibilityDimension::Resources, "tight");
561 assert_eq!(c.kind, FeasibilityKind::FeasibleWithConstraints);
562 assert!(!c.is_blocking());
563 }
564
565 #[test]
566 fn admission_from_dimensions_feasible_when_no_infeasible() {
567 let result = AdmissionResult::from_dimensions(vec![
568 FeasibilityAssessment::feasible(FeasibilityDimension::Capability, "ok"),
569 FeasibilityAssessment::with_constraints(FeasibilityDimension::Resources, "tight"),
570 FeasibilityAssessment::uncertain(FeasibilityDimension::Authority, "unclear"),
571 ]);
572 assert!(result.feasible);
573 assert!(result.rejection_reason.is_none());
574 assert_eq!(result.dimensions.len(), 3);
575 }
576
577 #[test]
578 fn admission_from_dimensions_infeasible_with_joined_reason() {
579 let result = AdmissionResult::from_dimensions(vec![
580 FeasibilityAssessment::feasible(FeasibilityDimension::Capability, "ok"),
581 FeasibilityAssessment::infeasible(FeasibilityDimension::Context, "missing outcome"),
582 FeasibilityAssessment::infeasible(FeasibilityDimension::Authority, "no authority"),
583 ]);
584 assert!(!result.feasible);
585 assert_eq!(
586 result.rejection_reason.as_deref(),
587 Some("missing outcome; no authority")
588 );
589 }
590
591 #[test]
592 fn admission_from_dimensions_empty_is_feasible() {
593 let result = AdmissionResult::from_dimensions(vec![]);
594 assert!(result.feasible);
595 assert!(result.rejection_reason.is_none());
596 assert!(result.dimensions.is_empty());
597 }
598
599 #[test]
600 fn forbidden_action_serde_roundtrip() {
601 let fa = ForbiddenAction {
602 action: "fire_all".into(),
603 reason: "HR policy".into(),
604 };
605 let json = serde_json::to_string(&fa).unwrap();
606 let back: ForbiddenAction = serde_json::from_str(&json).unwrap();
607 assert_eq!(back.action, "fire_all");
608 assert_eq!(back.reason, "HR policy");
609 }
610
611 #[test]
612 fn intent_node_leaf_is_leaf() {
613 let node = IntentNode::leaf(IntentPacket::new("leaf", future()));
614 assert!(node.is_leaf());
615 }
616
617 #[test]
618 fn intent_node_with_children_not_leaf() {
619 let child = IntentNode::leaf(IntentPacket::new("child", future()));
620 let parent = IntentNode {
621 id: Uuid::new_v4(),
622 intent: IntentPacket::new("parent", future()),
623 children: vec![child],
624 };
625 assert!(!parent.is_leaf());
626 }
627
628 #[test]
629 fn intent_error_display() {
630 let err = IntentError::Forbidden("no access".into());
631 assert_eq!(err.to_string(), "intent forbidden by rule: no access");
632
633 let err = IntentError::Infeasible("not enough resources".into());
634 assert_eq!(err.to_string(), "intent infeasible: not enough resources");
635 }
636
637 #[test]
638 fn intent_error_expired_display() {
639 let t = Utc::now();
640 let err = IntentError::Expired(t);
641 assert!(err.to_string().starts_with("intent expired at "));
642 }
643
644 #[test]
645 fn intent_packet_accepts_string_and_str() {
646 let from_str = IntentPacket::new("literal", future());
647 let from_string = IntentPacket::new(String::from("owned"), future());
648 assert_eq!(from_str.outcome, "literal");
649 assert_eq!(from_string.outcome, "owned");
650 }
651
652 #[test]
653 fn intent_packet_empty_outcome() {
654 let intent = IntentPacket::new("", future());
655 assert_eq!(intent.outcome, "");
656 }
657
658 #[test]
659 fn intent_packet_with_constraints() {
660 let mut intent = IntentPacket::new("constrained", future());
661 intent.constraints = vec!["budget < 10k".into(), "no external vendors".into()];
662 let json = serde_json::to_string(&intent).unwrap();
663 let back: IntentPacket = serde_json::from_str(&json).unwrap();
664 assert_eq!(back.constraints.len(), 2);
665 }
666}