Skip to main content

agentstategraph_core/
intent.rs

1//! Intent and Authority types — the provenance metadata that makes
2//! AgentStateGraph different from git.
3//!
4//! Every commit carries structured metadata about:
5//! - Why the change was made (Intent)
6//! - Who authorized it (Authority)
7//! - What the lifecycle status is (IntentLifecycle)
8//! - What was accomplished (Resolution)
9//! - Who should be notified (NotificationPolicy)
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14/// Unique identifier for an intent.
15pub type IntentId = String;
16
17/// Unique identifier for an agent.
18pub type AgentId = String;
19
20/// Unique identifier for a session.
21pub type SessionId = String;
22
23/// Unique identifier for a principal (human, agent, team, or policy).
24pub type Principal = String;
25
26// ---------------------------------------------------------------------------
27// Well-known intent tags
28// ---------------------------------------------------------------------------
29
30/// Opts a [`IntentCategory::Checkpoint`] commit into pinning its `state_root`
31/// on the distilled milestone row, which keeps that snapshot reachable through
32/// a GC sweep.
33///
34/// Pinning is **opt-in**. A checkpoint without this tag still earns a milestone
35/// row — the timeline entry, its description and its [`TAG_GIT_REVISION`]
36/// provenance all survive — but it names no snapshot, so a sweep is free to
37/// reclaim the objects behind it. That default is what keeps machine-generated
38/// checkpoints (a re-index, a sidecar hydrate) from pinning a full state tree
39/// apiece; without it a store accumulates one retained snapshot per routine run
40/// and almost nothing is ever reclaimable.
41///
42/// Tag a checkpoint with this only when the snapshot itself must stay
43/// materializable — a release, a ratified baseline, a human-named marker.
44pub const TAG_PIN_STATE: &str = "asg:pin-state";
45
46/// Prefix for the external VCS revision a commit corresponds to, e.g.
47/// `git:1a2b3c...`. The distilled milestone row lifts the value into its
48/// `git_sha` column.
49///
50/// This is what makes an unpinned milestone useful: the snapshot is gone, but
51/// the revision that produced it is recorded, so derived state can be rebuilt
52/// from source at that revision. Prefer the full hash — an abbreviation can
53/// stop being unique as a repository grows.
54pub const TAG_GIT_REVISION: &str = "git:";
55
56// ---------------------------------------------------------------------------
57// Intent
58// ---------------------------------------------------------------------------
59
60/// Why a state change is being made.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct Intent {
63    /// Unique identifier for this intent.
64    pub id: IntentId,
65    /// High-level category of this intent.
66    pub category: IntentCategory,
67    /// Human/agent-readable description of what this intent aims to accomplish.
68    pub description: String,
69    /// Queryable labels for filtering and search.
70    pub tags: Vec<String>,
71    /// If this intent was decomposed from a parent, the parent's ID. Set via
72    /// [`Intent::with_parent`]; consumed by `Repository::intent_tree` to build
73    /// the sub-intent hierarchy.
74    pub parent_intent: Option<IntentId>,
75    /// Current lifecycle state.
76    pub lifecycle: IntentLifecycle,
77}
78
79/// High-level categories for intents. These are queryable and filterable.
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub enum IntentCategory {
82    /// Trying an approach to evaluate it.
83    Explore,
84    /// Improving on a previous state.
85    Refine,
86    /// Correcting an error or regression.
87    Fix,
88    /// Reverting to a prior state.
89    Rollback,
90    /// Saving a known-good state.
91    Checkpoint,
92    /// Combining work from branches.
93    Merge,
94    /// Schema or structural change.
95    Migrate,
96    /// A change to a plan or task — creation, transition, proof attachment,
97    /// abandonment. Used by the `agentstategraph-tasks` crate and its
98    /// consumers so plan activity is natively filterable in log/blame queries.
99    Plan,
100    /// Marking a path as degraded (see `agentstategraph-taint` + spec/TAINT_SPEC.md).
101    Taint,
102    /// Resolving a taint with reason / proof.
103    Untaint,
104    /// Restricting a path to an authorized-agents allowlist.
105    Quarantine,
106    /// Releasing a quarantine with audit trail.
107    Unquarantine,
108    /// Advisory marker that draws attention without restricting access.
109    Watch,
110    /// Removing an advisory watch.
111    Unwatch,
112    /// A new policy has been proposed and is awaiting ratification.
113    /// Used by the `agentstategraph-policy` crate so policy lifecycle
114    /// is natively filterable in log/blame queries.
115    PolicyPropose,
116    /// A proposed policy has been ratified and is now live.
117    PolicyRatify,
118    /// An existing policy has been superseded by a newer version.
119    PolicySupersede,
120    /// A policy has been signed with an Ed25519 key.
121    PolicySign,
122    /// Application-defined category.
123    Custom(String),
124}
125
126// ---------------------------------------------------------------------------
127// Intent Lifecycle
128// ---------------------------------------------------------------------------
129
130/// Tracks the full arc from proposal through resolution and notification.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct IntentLifecycle {
133    /// Current status.
134    pub status: IntentStatus,
135    /// Agent(s) working on this intent.
136    pub assigned_to: Vec<AgentId>,
137    /// Filed when the intent reaches a terminal state.
138    pub resolution: Option<Resolution>,
139    /// Who should be notified and how.
140    pub notification: Option<NotificationPolicy>,
141}
142
143/// Valid states for an intent's lifecycle.
144///
145/// State machine:
146/// Proposed → Authorized → InProgress → Completed
147///                │                │ → Failed
148///                │                └─→ Blocked → InProgress
149///                └─→ (rejected)                     → Failed
150#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
151pub enum IntentStatus {
152    /// Intent has been declared.
153    Proposed,
154    /// Authority has approved execution.
155    Authorized,
156    /// Agent(s) are actively working.
157    InProgress,
158    /// Work is done, resolution filed.
159    Completed,
160    /// Agent could not fulfill the intent.
161    Failed,
162    /// Waiting on external dependency.
163    Blocked,
164}
165
166// ---------------------------------------------------------------------------
167// Resolution — the "report back"
168// ---------------------------------------------------------------------------
169
170/// Filed when an intent reaches a terminal state.
171/// This is the structured report that answers: "what was accomplished?"
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct Resolution {
174    /// Concise description of what was accomplished.
175    pub summary: String,
176    /// Where/why the agent diverged from the original plan.
177    pub deviations: Vec<Deviation>,
178    /// Commit IDs of state changes made while fulfilling this intent.
179    pub commits: Vec<String>,
180    /// Branches created during exploration.
181    pub branches_explored: Vec<String>,
182    /// Overall outcome.
183    pub outcome: Outcome,
184    /// Agent's self-assessed confidence in the result (0.0 to 1.0).
185    pub confidence: f64,
186}
187
188/// A record of where and why the agent diverged from the plan.
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct Deviation {
191    /// What was different from the plan.
192    pub description: String,
193    /// Why the deviation occurred.
194    pub reason: String,
195    /// Severity of the deviation.
196    pub impact: DeviationImpact,
197    /// Optional follow-up intent created to address this deviation.
198    pub follow_up: Option<IntentId>,
199}
200
201/// Severity of a deviation from plan.
202#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
203pub enum DeviationImpact {
204    Low,
205    Medium,
206    High,
207}
208
209/// The overall outcome of an intent's execution.
210#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
211pub enum Outcome {
212    /// Intent fully satisfied.
213    Fulfilled,
214    /// Some aspects completed, others remain.
215    PartiallyFulfilled,
216    /// Could not satisfy the intent.
217    Failed,
218    /// Punted to a follow-up intent.
219    Deferred,
220}
221
222// ---------------------------------------------------------------------------
223// Notification Policy
224// ---------------------------------------------------------------------------
225
226/// Declares who should be informed about an intent's resolution,
227/// at what urgency, and in what format.
228///
229/// AgentStateGraph does not deliver notifications directly — this is stored
230/// as part of the provenance record and emitted as a structured event.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct NotificationPolicy {
233    /// How urgent is this notification.
234    pub urgency: Urgency,
235    /// Principals who should be informed.
236    pub audience: Vec<Principal>,
237    /// Suggested format for the notification.
238    pub format_hint: FormatHint,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
242pub enum Urgency {
243    Routine,
244    Priority,
245    Critical,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
249pub enum FormatHint {
250    Summary,
251    Detailed,
252    DiffOnly,
253}
254
255// ---------------------------------------------------------------------------
256// Authority
257// ---------------------------------------------------------------------------
258
259/// Who authorized a state change, with the full delegation chain.
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261pub struct Authority {
262    /// The principal who authorized this action.
263    pub principal: Principal,
264    /// What was authorized.
265    pub scope: AuthScope,
266    /// When the authorization was granted.
267    pub granted_at: DateTime<Utc>,
268    /// When the authorization expires (None = no expiration).
269    pub expires: Option<DateTime<Utc>>,
270    /// Full authorization path from root policy to executing agent.
271    pub delegation_chain: Vec<DelegationLink>,
272}
273
274/// What an authority grants permission for.
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276pub enum AuthScope {
277    /// Authorized for a specific intent.
278    Intent(IntentId),
279    /// Authorized for branches matching a pattern.
280    Branch(String),
281    /// Authorized for paths matching a pattern.
282    Path(String),
283    /// Full access.
284    Wildcard,
285    /// Application-defined scope.
286    Custom(String),
287}
288
289/// A single hop in a delegation chain.
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291pub struct DelegationLink {
292    /// Delegating principal.
293    pub from: Principal,
294    /// Receiving principal.
295    pub to: Principal,
296    /// What was delegated.
297    pub scope: AuthScope,
298    /// When the delegation was granted.
299    pub granted_at: DateTime<Utc>,
300    /// When the delegation expires.
301    pub expires: Option<DateTime<Utc>>,
302}
303
304// ---------------------------------------------------------------------------
305// Tool Call provenance
306// ---------------------------------------------------------------------------
307
308/// A record of a tool call that contributed to a state change.
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310pub struct ToolCall {
311    /// Name of the tool (e.g., "kubectl_apply", "stategraph_set").
312    pub tool_name: String,
313    /// Input arguments.
314    pub arguments: serde_json::Value,
315    /// Summary of the result (not the full output).
316    pub result: Option<String>,
317    /// When the tool was called.
318    pub timestamp: DateTime<Utc>,
319}
320
321// ---------------------------------------------------------------------------
322// Convenience constructors
323// ---------------------------------------------------------------------------
324
325impl Intent {
326    /// Create a new intent with minimal required fields.
327    /// Lifecycle starts as Proposed.
328    pub fn new(category: IntentCategory, description: impl Into<String>) -> Self {
329        Self {
330            id: uuid::Uuid::new_v4().to_string(),
331            category,
332            description: description.into(),
333            tags: Vec::new(),
334            parent_intent: None,
335            lifecycle: IntentLifecycle {
336                status: IntentStatus::Proposed,
337                assigned_to: Vec::new(),
338                resolution: None,
339                notification: None,
340            },
341        }
342    }
343
344    /// Add tags to this intent.
345    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
346        self.tags = tags;
347        self
348    }
349
350    /// Set the parent intent (for decomposition / sub-intents).
351    pub fn with_parent(mut self, parent_id: IntentId) -> Self {
352        self.parent_intent = Some(parent_id);
353        self
354    }
355}
356
357impl Authority {
358    /// Create a simple authority with no delegation chain.
359    pub fn simple(principal: impl Into<String>) -> Self {
360        Self {
361            principal: principal.into(),
362            scope: AuthScope::Wildcard,
363            granted_at: Utc::now(),
364            expires: None,
365            delegation_chain: Vec::new(),
366        }
367    }
368
369    /// Create an authority scoped to a specific intent.
370    pub fn for_intent(principal: impl Into<String>, intent_id: IntentId) -> Self {
371        Self {
372            principal: principal.into(),
373            scope: AuthScope::Intent(intent_id),
374            granted_at: Utc::now(),
375            expires: None,
376            delegation_chain: Vec::new(),
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn plan_variant_serializes_as_native() {
387        let c = IntentCategory::Plan;
388        let json = serde_json::to_string(&c).unwrap();
389        assert_eq!(json, "\"Plan\"");
390    }
391
392    #[test]
393    fn plan_variant_deserializes_from_native_form() {
394        let c: IntentCategory = serde_json::from_str("\"Plan\"").unwrap();
395        assert_eq!(c, IntentCategory::Plan);
396    }
397
398    /// The `Plan` variant is additive — pre-existing data written as
399    /// `Custom("Plan")` must continue to deserialize as the Custom
400    /// variant, not silently collapse into the new native one. This
401    /// preserves on-disk round-trip for any consumer who wrote
402    /// `Custom("Plan")` to storage before 0.4.0.
403    ///
404    /// **Caveat for consumers**: the two forms are now distinct. A
405    /// filter on `IntentCategory::Plan` will NOT match old
406    /// `Custom("Plan")` commits. If you have such data and want to
407    /// unify it, normalise at read time.
408    #[test]
409    fn custom_plan_string_still_round_trips_as_custom() {
410        let c = IntentCategory::Custom("Plan".to_string());
411        let json = serde_json::to_string(&c).unwrap();
412        assert_eq!(json, "{\"Custom\":\"Plan\"}");
413
414        let back: IntentCategory = serde_json::from_str(&json).unwrap();
415        assert_eq!(back, IntentCategory::Custom("Plan".to_string()));
416        assert_ne!(back, IntentCategory::Plan);
417    }
418
419    #[test]
420    fn all_native_variants_round_trip() {
421        for c in [
422            IntentCategory::Explore,
423            IntentCategory::Refine,
424            IntentCategory::Fix,
425            IntentCategory::Rollback,
426            IntentCategory::Checkpoint,
427            IntentCategory::Merge,
428            IntentCategory::Migrate,
429            IntentCategory::Plan,
430            IntentCategory::Taint,
431            IntentCategory::Untaint,
432            IntentCategory::Quarantine,
433            IntentCategory::Unquarantine,
434            IntentCategory::Watch,
435            IntentCategory::Unwatch,
436            IntentCategory::PolicyPropose,
437            IntentCategory::PolicyRatify,
438            IntentCategory::PolicySupersede,
439            IntentCategory::PolicySign,
440        ] {
441            let json = serde_json::to_string(&c).unwrap();
442            let back: IntentCategory = serde_json::from_str(&json).unwrap();
443            assert_eq!(c, back, "round-trip failed for {:?}", c);
444        }
445    }
446
447    #[test]
448    fn policy_variants_serialize_as_native() {
449        assert_eq!(
450            serde_json::to_string(&IntentCategory::PolicyPropose).unwrap(),
451            "\"PolicyPropose\""
452        );
453        assert_eq!(
454            serde_json::to_string(&IntentCategory::PolicyRatify).unwrap(),
455            "\"PolicyRatify\""
456        );
457        assert_eq!(
458            serde_json::to_string(&IntentCategory::PolicySupersede).unwrap(),
459            "\"PolicySupersede\""
460        );
461        assert_eq!(
462            serde_json::to_string(&IntentCategory::PolicySign).unwrap(),
463            "\"PolicySign\""
464        );
465    }
466
467    /// Pre-existing data written as `Custom("PolicyPropose")` etc. must
468    /// remain distinct from the new native variants, matching the same
469    /// backward-compatibility guarantee given to `Plan` and the taint
470    /// variants.
471    #[test]
472    fn custom_policy_strings_still_round_trip_as_custom() {
473        for name in [
474            "PolicyPropose",
475            "PolicyRatify",
476            "PolicySupersede",
477            "PolicySign",
478        ] {
479            let c = IntentCategory::Custom(name.to_string());
480            let json = serde_json::to_string(&c).unwrap();
481            assert_eq!(json, format!("{{\"Custom\":\"{}\"}}", name));
482            let back: IntentCategory = serde_json::from_str(&json).unwrap();
483            assert_eq!(back, IntentCategory::Custom(name.to_string()));
484        }
485    }
486
487    /// Mirrors `custom_plan_string_still_round_trips_as_custom` for
488    /// the 0.7.75 taint variants. Pre-existing data written as
489    /// `Custom("Taint")` etc. must remain distinct from the new
490    /// native variants so consumers can unambiguously distinguish
491    /// pre-0.7.75 ad-hoc taint events from native ones.
492    #[test]
493    fn custom_taint_string_still_round_trips_as_custom() {
494        for name in [
495            "Taint",
496            "Untaint",
497            "Quarantine",
498            "Unquarantine",
499            "Watch",
500            "Unwatch",
501        ] {
502            let c = IntentCategory::Custom(name.to_string());
503            let json = serde_json::to_string(&c).unwrap();
504            assert_eq!(json, format!("{{\"Custom\":\"{}\"}}", name));
505            let back: IntentCategory = serde_json::from_str(&json).unwrap();
506            assert_eq!(back, IntentCategory::Custom(name.to_string()));
507        }
508    }
509
510    #[test]
511    fn taint_variants_serialize_as_native() {
512        assert_eq!(
513            serde_json::to_string(&IntentCategory::Taint).unwrap(),
514            "\"Taint\""
515        );
516        assert_eq!(
517            serde_json::to_string(&IntentCategory::Quarantine).unwrap(),
518            "\"Quarantine\""
519        );
520        assert_eq!(
521            serde_json::to_string(&IntentCategory::Watch).unwrap(),
522            "\"Watch\""
523        );
524    }
525}