agentstategraph-core 1.3.0

Core types and algorithms for AgentStateGraph — zero I/O dependencies
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
//! Intent and Authority types — the provenance metadata that makes
//! AgentStateGraph different from git.
//!
//! Every commit carries structured metadata about:
//! - Why the change was made (Intent)
//! - Who authorized it (Authority)
//! - What the lifecycle status is (IntentLifecycle)
//! - What was accomplished (Resolution)
//! - Who should be notified (NotificationPolicy)

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Unique identifier for an intent.
pub type IntentId = String;

/// Unique identifier for an agent.
pub type AgentId = String;

/// Unique identifier for a session.
pub type SessionId = String;

/// Unique identifier for a principal (human, agent, team, or policy).
pub type Principal = String;

// ---------------------------------------------------------------------------
// Well-known intent tags
// ---------------------------------------------------------------------------

/// Opts a [`IntentCategory::Checkpoint`] commit into pinning its `state_root`
/// on the distilled milestone row, which keeps that snapshot reachable through
/// a GC sweep.
///
/// Pinning is **opt-in**. A checkpoint without this tag still earns a milestone
/// row — the timeline entry, its description and its [`TAG_GIT_REVISION`]
/// provenance all survive — but it names no snapshot, so a sweep is free to
/// reclaim the objects behind it. That default is what keeps machine-generated
/// checkpoints (a re-index, a sidecar hydrate) from pinning a full state tree
/// apiece; without it a store accumulates one retained snapshot per routine run
/// and almost nothing is ever reclaimable.
///
/// Tag a checkpoint with this only when the snapshot itself must stay
/// materializable — a release, a ratified baseline, a human-named marker.
pub const TAG_PIN_STATE: &str = "asg:pin-state";

/// Prefix for the external VCS revision a commit corresponds to, e.g.
/// `git:1a2b3c...`. The distilled milestone row lifts the value into its
/// `git_sha` column.
///
/// This is what makes an unpinned milestone useful: the snapshot is gone, but
/// the revision that produced it is recorded, so derived state can be rebuilt
/// from source at that revision. Prefer the full hash — an abbreviation can
/// stop being unique as a repository grows.
pub const TAG_GIT_REVISION: &str = "git:";

// ---------------------------------------------------------------------------
// Intent
// ---------------------------------------------------------------------------

/// Why a state change is being made.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Intent {
    /// Unique identifier for this intent.
    pub id: IntentId,
    /// High-level category of this intent.
    pub category: IntentCategory,
    /// Human/agent-readable description of what this intent aims to accomplish.
    pub description: String,
    /// Queryable labels for filtering and search.
    pub tags: Vec<String>,
    /// If this intent was decomposed from a parent, the parent's ID. Set via
    /// [`Intent::with_parent`]; consumed by `Repository::intent_tree` to build
    /// the sub-intent hierarchy.
    pub parent_intent: Option<IntentId>,
    /// Current lifecycle state.
    pub lifecycle: IntentLifecycle,
}

/// High-level categories for intents. These are queryable and filterable.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum IntentCategory {
    /// Trying an approach to evaluate it.
    Explore,
    /// Improving on a previous state.
    Refine,
    /// Correcting an error or regression.
    Fix,
    /// Reverting to a prior state.
    Rollback,
    /// Saving a known-good state.
    Checkpoint,
    /// Combining work from branches.
    Merge,
    /// Schema or structural change.
    Migrate,
    /// A change to a plan or task — creation, transition, proof attachment,
    /// abandonment. Used by the `agentstategraph-tasks` crate and its
    /// consumers so plan activity is natively filterable in log/blame queries.
    Plan,
    /// Marking a path as degraded (see `agentstategraph-taint` + spec/TAINT_SPEC.md).
    Taint,
    /// Resolving a taint with reason / proof.
    Untaint,
    /// Restricting a path to an authorized-agents allowlist.
    Quarantine,
    /// Releasing a quarantine with audit trail.
    Unquarantine,
    /// Advisory marker that draws attention without restricting access.
    Watch,
    /// Removing an advisory watch.
    Unwatch,
    /// A new policy has been proposed and is awaiting ratification.
    /// Used by the `agentstategraph-policy` crate so policy lifecycle
    /// is natively filterable in log/blame queries.
    PolicyPropose,
    /// A proposed policy has been ratified and is now live.
    PolicyRatify,
    /// An existing policy has been superseded by a newer version.
    PolicySupersede,
    /// A policy has been signed with an Ed25519 key.
    PolicySign,
    /// Application-defined category.
    Custom(String),
}

// ---------------------------------------------------------------------------
// Intent Lifecycle
// ---------------------------------------------------------------------------

/// Tracks the full arc from proposal through resolution and notification.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntentLifecycle {
    /// Current status.
    pub status: IntentStatus,
    /// Agent(s) working on this intent.
    pub assigned_to: Vec<AgentId>,
    /// Filed when the intent reaches a terminal state.
    pub resolution: Option<Resolution>,
    /// Who should be notified and how.
    pub notification: Option<NotificationPolicy>,
}

/// Valid states for an intent's lifecycle.
///
/// State machine:
/// Proposed → Authorized → InProgress → Completed
///                │                │ → Failed
///                │                └─→ Blocked → InProgress
///                └─→ (rejected)                     → Failed
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum IntentStatus {
    /// Intent has been declared.
    Proposed,
    /// Authority has approved execution.
    Authorized,
    /// Agent(s) are actively working.
    InProgress,
    /// Work is done, resolution filed.
    Completed,
    /// Agent could not fulfill the intent.
    Failed,
    /// Waiting on external dependency.
    Blocked,
}

// ---------------------------------------------------------------------------
// Resolution — the "report back"
// ---------------------------------------------------------------------------

/// Filed when an intent reaches a terminal state.
/// This is the structured report that answers: "what was accomplished?"
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Resolution {
    /// Concise description of what was accomplished.
    pub summary: String,
    /// Where/why the agent diverged from the original plan.
    pub deviations: Vec<Deviation>,
    /// Commit IDs of state changes made while fulfilling this intent.
    pub commits: Vec<String>,
    /// Branches created during exploration.
    pub branches_explored: Vec<String>,
    /// Overall outcome.
    pub outcome: Outcome,
    /// Agent's self-assessed confidence in the result (0.0 to 1.0).
    pub confidence: f64,
}

/// A record of where and why the agent diverged from the plan.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Deviation {
    /// What was different from the plan.
    pub description: String,
    /// Why the deviation occurred.
    pub reason: String,
    /// Severity of the deviation.
    pub impact: DeviationImpact,
    /// Optional follow-up intent created to address this deviation.
    pub follow_up: Option<IntentId>,
}

/// Severity of a deviation from plan.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DeviationImpact {
    Low,
    Medium,
    High,
}

/// The overall outcome of an intent's execution.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Outcome {
    /// Intent fully satisfied.
    Fulfilled,
    /// Some aspects completed, others remain.
    PartiallyFulfilled,
    /// Could not satisfy the intent.
    Failed,
    /// Punted to a follow-up intent.
    Deferred,
}

// ---------------------------------------------------------------------------
// Notification Policy
// ---------------------------------------------------------------------------

/// Declares who should be informed about an intent's resolution,
/// at what urgency, and in what format.
///
/// AgentStateGraph does not deliver notifications directly — this is stored
/// as part of the provenance record and emitted as a structured event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NotificationPolicy {
    /// How urgent is this notification.
    pub urgency: Urgency,
    /// Principals who should be informed.
    pub audience: Vec<Principal>,
    /// Suggested format for the notification.
    pub format_hint: FormatHint,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Urgency {
    Routine,
    Priority,
    Critical,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FormatHint {
    Summary,
    Detailed,
    DiffOnly,
}

// ---------------------------------------------------------------------------
// Authority
// ---------------------------------------------------------------------------

/// Who authorized a state change, with the full delegation chain.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Authority {
    /// The principal who authorized this action.
    pub principal: Principal,
    /// What was authorized.
    pub scope: AuthScope,
    /// When the authorization was granted.
    pub granted_at: DateTime<Utc>,
    /// When the authorization expires (None = no expiration).
    pub expires: Option<DateTime<Utc>>,
    /// Full authorization path from root policy to executing agent.
    pub delegation_chain: Vec<DelegationLink>,
}

/// What an authority grants permission for.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AuthScope {
    /// Authorized for a specific intent.
    Intent(IntentId),
    /// Authorized for branches matching a pattern.
    Branch(String),
    /// Authorized for paths matching a pattern.
    Path(String),
    /// Full access.
    Wildcard,
    /// Application-defined scope.
    Custom(String),
}

/// A single hop in a delegation chain.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DelegationLink {
    /// Delegating principal.
    pub from: Principal,
    /// Receiving principal.
    pub to: Principal,
    /// What was delegated.
    pub scope: AuthScope,
    /// When the delegation was granted.
    pub granted_at: DateTime<Utc>,
    /// When the delegation expires.
    pub expires: Option<DateTime<Utc>>,
}

// ---------------------------------------------------------------------------
// Tool Call provenance
// ---------------------------------------------------------------------------

/// A record of a tool call that contributed to a state change.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
    /// Name of the tool (e.g., "kubectl_apply", "stategraph_set").
    pub tool_name: String,
    /// Input arguments.
    pub arguments: serde_json::Value,
    /// Summary of the result (not the full output).
    pub result: Option<String>,
    /// When the tool was called.
    pub timestamp: DateTime<Utc>,
}

// ---------------------------------------------------------------------------
// Convenience constructors
// ---------------------------------------------------------------------------

impl Intent {
    /// Create a new intent with minimal required fields.
    /// Lifecycle starts as Proposed.
    pub fn new(category: IntentCategory, description: impl Into<String>) -> Self {
        Self {
            id: uuid::Uuid::new_v4().to_string(),
            category,
            description: description.into(),
            tags: Vec::new(),
            parent_intent: None,
            lifecycle: IntentLifecycle {
                status: IntentStatus::Proposed,
                assigned_to: Vec::new(),
                resolution: None,
                notification: None,
            },
        }
    }

    /// Add tags to this intent.
    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
        self.tags = tags;
        self
    }

    /// Set the parent intent (for decomposition / sub-intents).
    pub fn with_parent(mut self, parent_id: IntentId) -> Self {
        self.parent_intent = Some(parent_id);
        self
    }
}

impl Authority {
    /// Create a simple authority with no delegation chain.
    pub fn simple(principal: impl Into<String>) -> Self {
        Self {
            principal: principal.into(),
            scope: AuthScope::Wildcard,
            granted_at: Utc::now(),
            expires: None,
            delegation_chain: Vec::new(),
        }
    }

    /// Create an authority scoped to a specific intent.
    pub fn for_intent(principal: impl Into<String>, intent_id: IntentId) -> Self {
        Self {
            principal: principal.into(),
            scope: AuthScope::Intent(intent_id),
            granted_at: Utc::now(),
            expires: None,
            delegation_chain: Vec::new(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn plan_variant_serializes_as_native() {
        let c = IntentCategory::Plan;
        let json = serde_json::to_string(&c).unwrap();
        assert_eq!(json, "\"Plan\"");
    }

    #[test]
    fn plan_variant_deserializes_from_native_form() {
        let c: IntentCategory = serde_json::from_str("\"Plan\"").unwrap();
        assert_eq!(c, IntentCategory::Plan);
    }

    /// The `Plan` variant is additive — pre-existing data written as
    /// `Custom("Plan")` must continue to deserialize as the Custom
    /// variant, not silently collapse into the new native one. This
    /// preserves on-disk round-trip for any consumer who wrote
    /// `Custom("Plan")` to storage before 0.4.0.
    ///
    /// **Caveat for consumers**: the two forms are now distinct. A
    /// filter on `IntentCategory::Plan` will NOT match old
    /// `Custom("Plan")` commits. If you have such data and want to
    /// unify it, normalise at read time.
    #[test]
    fn custom_plan_string_still_round_trips_as_custom() {
        let c = IntentCategory::Custom("Plan".to_string());
        let json = serde_json::to_string(&c).unwrap();
        assert_eq!(json, "{\"Custom\":\"Plan\"}");

        let back: IntentCategory = serde_json::from_str(&json).unwrap();
        assert_eq!(back, IntentCategory::Custom("Plan".to_string()));
        assert_ne!(back, IntentCategory::Plan);
    }

    #[test]
    fn all_native_variants_round_trip() {
        for c in [
            IntentCategory::Explore,
            IntentCategory::Refine,
            IntentCategory::Fix,
            IntentCategory::Rollback,
            IntentCategory::Checkpoint,
            IntentCategory::Merge,
            IntentCategory::Migrate,
            IntentCategory::Plan,
            IntentCategory::Taint,
            IntentCategory::Untaint,
            IntentCategory::Quarantine,
            IntentCategory::Unquarantine,
            IntentCategory::Watch,
            IntentCategory::Unwatch,
            IntentCategory::PolicyPropose,
            IntentCategory::PolicyRatify,
            IntentCategory::PolicySupersede,
            IntentCategory::PolicySign,
        ] {
            let json = serde_json::to_string(&c).unwrap();
            let back: IntentCategory = serde_json::from_str(&json).unwrap();
            assert_eq!(c, back, "round-trip failed for {:?}", c);
        }
    }

    #[test]
    fn policy_variants_serialize_as_native() {
        assert_eq!(
            serde_json::to_string(&IntentCategory::PolicyPropose).unwrap(),
            "\"PolicyPropose\""
        );
        assert_eq!(
            serde_json::to_string(&IntentCategory::PolicyRatify).unwrap(),
            "\"PolicyRatify\""
        );
        assert_eq!(
            serde_json::to_string(&IntentCategory::PolicySupersede).unwrap(),
            "\"PolicySupersede\""
        );
        assert_eq!(
            serde_json::to_string(&IntentCategory::PolicySign).unwrap(),
            "\"PolicySign\""
        );
    }

    /// Pre-existing data written as `Custom("PolicyPropose")` etc. must
    /// remain distinct from the new native variants, matching the same
    /// backward-compatibility guarantee given to `Plan` and the taint
    /// variants.
    #[test]
    fn custom_policy_strings_still_round_trip_as_custom() {
        for name in [
            "PolicyPropose",
            "PolicyRatify",
            "PolicySupersede",
            "PolicySign",
        ] {
            let c = IntentCategory::Custom(name.to_string());
            let json = serde_json::to_string(&c).unwrap();
            assert_eq!(json, format!("{{\"Custom\":\"{}\"}}", name));
            let back: IntentCategory = serde_json::from_str(&json).unwrap();
            assert_eq!(back, IntentCategory::Custom(name.to_string()));
        }
    }

    /// Mirrors `custom_plan_string_still_round_trips_as_custom` for
    /// the 0.7.75 taint variants. Pre-existing data written as
    /// `Custom("Taint")` etc. must remain distinct from the new
    /// native variants so consumers can unambiguously distinguish
    /// pre-0.7.75 ad-hoc taint events from native ones.
    #[test]
    fn custom_taint_string_still_round_trips_as_custom() {
        for name in [
            "Taint",
            "Untaint",
            "Quarantine",
            "Unquarantine",
            "Watch",
            "Unwatch",
        ] {
            let c = IntentCategory::Custom(name.to_string());
            let json = serde_json::to_string(&c).unwrap();
            assert_eq!(json, format!("{{\"Custom\":\"{}\"}}", name));
            let back: IntentCategory = serde_json::from_str(&json).unwrap();
            assert_eq!(back, IntentCategory::Custom(name.to_string()));
        }
    }

    #[test]
    fn taint_variants_serialize_as_native() {
        assert_eq!(
            serde_json::to_string(&IntentCategory::Taint).unwrap(),
            "\"Taint\""
        );
        assert_eq!(
            serde_json::to_string(&IntentCategory::Quarantine).unwrap(),
            "\"Quarantine\""
        );
        assert_eq!(
            serde_json::to_string(&IntentCategory::Watch).unwrap(),
            "\"Watch\""
        );
    }
}