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}