shigoto-types 0.1.9

shigoto — typed primitives (Job, JobId, JobPhase, JobKindId, JobScope, JobSubject, TickReceipt, Snapshot).
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
//! `converge` — the universal state-convergence trait + typed Plan/Outcome border.
//!
//! RE-HOMED from `magma-converge` (2026-06-05, theory/CONVERGENCE-ADOPTION.md):
//! the `Reconciler` trait + its entire data border (`Plan`/`Outcome`/`Change`/
//! `Action`/`ChangeSeverity`/`PlanId`/`AppliedChange`/`FailedChange`/`ReconcilerError`
//! + `ApplyMetrics` + the `build_outcome`/`change`/`change_with_severity` helpers) is
//! serde-only — it carries zero IaC/gen-platform/tonic weight, so it joins its sibling
//! convergence primitives (`policy::CascadePolicy`, `decision::Decision`, `sink::Sink`,
//! `classify::Classifier`, `watch::TimeoutWatcher`, `failure::FailureKind`) in the
//! lightweight `shigoto-types`. magma-converge re-exports every name for back-compat,
//! exactly as `policy.rs`/`decision.rs` already do. State/config/plan stay type-erased
//! `serde_json::Value` so the trait is object-safe (`SharedReconciler = Arc<dyn ...>`).
//!
//! Per `theory/CONVERGENCE-SUBSTRATE.md`.

use std::sync::Arc;

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;

// ── Errors ─────────────────────────────────────────────────────────

#[derive(Debug, Error)]
pub enum ReconcilerError {
    #[error("read state: {0}")]
    ReadState(String),
    #[error("compute plan: {0}")]
    ComputePlan(String),
    #[error("apply: {0}")]
    Apply(String),
    #[error("validation: {0}")]
    Validation(String),
    #[error("transient: {0}")]
    Transient(String),
    #[error("other: {0}")]
    Other(String),
}

// ── Universal Plan + Change types ──────────────────────────────────

/// A typed plan_id is BLAKE3 of the canonical plan-body bytes.
/// Deterministic — same `(config, state, reconciler kind)` produce
/// equal plan_ids.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PlanId(pub String);

impl PlanId {
    /// Compute a plan id from canonical bytes.
    pub fn of(bytes: &[u8]) -> Self {
        Self(hex::encode(blake3::hash(bytes).as_bytes()))
    }
}

/// What kind of operation a `Change` represents. The five shapes
/// every reconciler emits.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
    Create,
    Update,
    Delete,
    Replace,
    NoOp,
}

/// Severity classification — used by `magma-drift` to decide
/// auto-correct vs alert vs require-approval. Reconciler impls set
/// reasonable defaults; downstream policy can override.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeSeverity {
    /// Tags, labels, descriptions — usually safe to auto-fix.
    Cosmetic,
    /// Behavior-altering but not security-relevant.
    Functional,
    /// Security/availability-relevant — IAM, SG rules, encryption,
    /// data loss vectors. Default to require-approval.
    Critical,
}

/// One typed resource-level change inside a `Plan`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Change {
    /// Resource address — kind-specific (e.g. `aws_vpc.net`,
    /// `github_repo.foo`, `dns_record.bar`).
    pub address: String,
    /// Action this change represents.
    pub action: Action,
    /// Severity for drift-policy routing.
    pub severity: ChangeSeverity,
    /// Pre-state JSON (None for Create).
    pub before: Option<Value>,
    /// Post-state JSON (None for Delete).
    pub after: Option<Value>,
}

/// A typed Plan — universal across reconciler kinds.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
    /// BLAKE3-derived plan id; deterministic for `(config, state, kind)`.
    pub id: PlanId,
    /// The kind that produced this plan ("terraform", "github", …).
    pub kind: String,
    /// When the plan was computed. NOT included in plan_id derivation.
    pub created_at: DateTime<Utc>,
    /// The typed changes the plan would apply.
    pub changes: Vec<Change>,
}

impl Plan {
    /// Compute a plan id from changes + kind. Deterministic;
    /// `created_at` is not part of the hash so two plans computed
    /// at different times against identical (config, state) are
    /// id-equal.
    pub fn derive_id(kind: &str, changes: &[Change]) -> PlanId {
        // Serialize a canonical projection: kind + each change as
        // (address, action, severity, before, after). Avoids
        // serializing `created_at` or anything else that varies.
        let projection: Vec<Value> = changes
            .iter()
            .map(|c| {
                serde_json::json!({
                    "address":  c.address,
                    "action":   c.action,
                    "severity": c.severity,
                    "before":   c.before,
                    "after":    c.after,
                })
            })
            .collect();
        let canonical = serde_json::json!({ "kind": kind, "changes": projection });
        PlanId::of(
            serde_json::to_vec(&canonical)
                .unwrap_or_default()
                .as_slice(),
        )
    }

    /// Build a Plan with `derive_id`-computed id + `Utc::now`.
    pub fn new(kind: impl Into<String>, changes: Vec<Change>) -> Self {
        let kind = kind.into();
        let id = Self::derive_id(&kind, &changes);
        Self {
            id,
            kind,
            created_at: Utc::now(),
            changes,
        }
    }

    /// True iff the plan has no Create/Update/Delete/Replace changes.
    pub fn is_noop(&self) -> bool {
        self.changes
            .iter()
            .all(|c| matches!(c.action, Action::NoOp))
    }

    /// Number of non-noop changes.
    pub fn change_count(&self) -> usize {
        self.changes
            .iter()
            .filter(|c| !matches!(c.action, Action::NoOp))
            .count()
    }
}

/// One `apply` step's record — what landed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppliedChange {
    pub address: String,
    pub action: Action,
}

/// One `apply` step's failure.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailedChange {
    pub address: String,
    pub action: Action,
    pub error: String,
}

/// Universal apply Outcome.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Outcome {
    pub plan_id: PlanId,
    pub kind: String,
    pub applied: Vec<AppliedChange>,
    pub failed: Vec<FailedChange>,
    pub started_at: DateTime<Utc>,
    pub finished_at: DateTime<Utc>,
}

impl Outcome {
    pub fn fully_succeeded(&self) -> bool {
        self.failed.is_empty()
    }
}

// ── The Reconciler trait ──────────────────────────────────────────

/// The universal reconciler trait. Any declarative API can implement
/// this; the engine routes typed `(config, kind)` pairs to the right
/// reconciler.
///
/// # Laws (every impl must obey)
///
/// 1. **`read_state` is referentially transparent** modulo external
///    change.
/// 2. **`compute_plan` is deterministic.** Same `(config, state)` →
///    equal `Plan` (modulo `created_at`) and equal `plan_id`.
/// 3. **Empty plans are no-ops.** `apply(noop_plan)` doesn't mutate
///    observable state.
/// 4. **Apply converges.** After `apply(plan)`, the next
///    `detect_drift(config)` produces an empty Plan (assuming no
///    external mutation).
#[async_trait]
pub trait Reconciler: Send + Sync {
    /// Stable kind name. Used by the engine for routing and by
    /// downstream consumers (drift, attestation) to label events.
    fn kind(&self) -> &'static str;

    /// Read current observed state.
    async fn read_state(&self) -> Result<Value, ReconcilerError>;

    /// Compute the typed plan. Pure function — no I/O. Determinism
    /// is a trait law.
    fn compute_plan(&self, config: &Value, state: &Value) -> Result<Plan, ReconcilerError>;

    /// Apply a typed plan. Returns a typed Outcome.
    async fn apply(&self, plan: &Plan) -> Result<Outcome, ReconcilerError>;

    /// Convenience: drift detection = compute_plan(config, read_state()).
    async fn detect_drift(&self, config: &Value) -> Result<Plan, ReconcilerError> {
        let state = self.read_state().await?;
        self.compute_plan(config, &state)
    }
}

/// Type-erased Arc<dyn Reconciler> for engine storage.
pub type SharedReconciler = Arc<dyn Reconciler>;

// ── ApplyMetrics — instrumentation seam ────────────────────────────

/// Lightweight metrics hook that wrappers (e.g. `BudgetedReconciler`)
/// call at apply boundaries. magma-converge stays free of Prometheus
/// / metrics-rs / OpenTelemetry — implementors plug in whatever they
/// want. `magma-metrics::Metrics` is the canonical Prometheus impl.
///
/// Per `theory/CONVERGENCE-SUBSTRATE.md` §IV.3.
pub trait ApplyMetrics: Send + Sync {
    /// Called when a reconciler-driven `apply` begins. Implementors
    /// typically increment an `in_flight` gauge.
    fn apply_started(&self, kind: &str);

    /// Called when an apply finishes (success OR failure).
    /// Implementors typically decrement the same gauge.
    fn apply_finished(&self, kind: &str);
}

/// No-op implementation — useful when callers don't want
/// instrumentation. `Arc::new(NoMetrics)` satisfies the trait
/// without any side effects.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoMetrics;

impl ApplyMetrics for NoMetrics {
    fn apply_started(&self, _kind: &str) {}
    fn apply_finished(&self, _kind: &str) {}
}

// ── Convenience helpers ───────────────────────────────────────────

/// Build an `Outcome` shape matching the plan_id + kind, with the
/// given applied + failed lists.
pub fn build_outcome(
    plan: &Plan,
    applied: Vec<AppliedChange>,
    failed: Vec<FailedChange>,
    started_at: DateTime<Utc>,
) -> Outcome {
    Outcome {
        plan_id: plan.id.clone(),
        kind: plan.kind.clone(),
        applied,
        failed,
        started_at,
        finished_at: Utc::now(),
    }
}

/// Build a `Change` with a sensible default severity inferred from
/// the action. Reconcilers can override per-resource.
pub fn change(
    address: impl Into<String>,
    action: Action,
    before: Option<Value>,
    after: Option<Value>,
) -> Change {
    let severity = match action {
        Action::Create => ChangeSeverity::Functional,
        Action::Update => ChangeSeverity::Functional,
        Action::Delete => ChangeSeverity::Critical,
        Action::Replace => ChangeSeverity::Critical,
        Action::NoOp => ChangeSeverity::Cosmetic,
    };
    Change {
        address: address.into(),
        action,
        severity,
        before,
        after,
    }
}

/// Build a `Change` with explicit severity.
pub fn change_with_severity(
    address: impl Into<String>,
    action: Action,
    severity: ChangeSeverity,
    before: Option<Value>,
    after: Option<Value>,
) -> Change {
    Change {
        address: address.into(),
        action,
        severity,
        before,
        after,
    }
}

// ── Tests ──────────────────────────────────────────────────────────

#[cfg(test)]
mod core_tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn plan_id_is_deterministic() {
        let changes = vec![
            change("a", Action::Create, None, Some(json!({"k": 1}))),
            change(
                "b",
                Action::Update,
                Some(json!({"v": 1})),
                Some(json!({"v": 2})),
            ),
        ];
        let id1 = Plan::derive_id("test", &changes);
        let id2 = Plan::derive_id("test", &changes);
        assert_eq!(id1, id2);
        // Length is hex-encoded BLAKE3 (32 bytes → 64 hex chars).
        assert_eq!(id1.0.len(), 64);
    }

    #[test]
    fn plan_id_changes_with_kind() {
        let changes = vec![change("a", Action::Create, None, Some(json!({})))];
        let id_terraform = Plan::derive_id("terraform", &changes);
        let id_github = Plan::derive_id("github", &changes);
        assert_ne!(id_terraform, id_github);
    }

    #[test]
    fn plan_id_changes_with_changes() {
        let id1 = Plan::derive_id("k", &[change("a", Action::Create, None, Some(json!({})))]);
        let id2 = Plan::derive_id("k", &[change("a", Action::Delete, Some(json!({})), None)]);
        assert_ne!(id1, id2);
    }

    #[test]
    fn plan_id_is_independent_of_created_at() {
        let changes = vec![change("a", Action::Create, None, Some(json!({})))];
        let p1 = Plan::new("k", changes.clone());
        // Force a tiny delay so created_at differs.
        std::thread::sleep(std::time::Duration::from_millis(1));
        let p2 = Plan::new("k", changes);
        assert_eq!(p1.id, p2.id);
        assert_ne!(p1.created_at, p2.created_at);
    }

    #[test]
    fn is_noop_handles_mixed_changes() {
        let noop_plan = Plan::new("k", vec![change("a", Action::NoOp, None, None)]);
        assert!(noop_plan.is_noop());
        let mixed = Plan::new(
            "k",
            vec![
                change("a", Action::NoOp, None, None),
                change("b", Action::Create, None, Some(json!({}))),
            ],
        );
        assert!(!mixed.is_noop());
        let empty = Plan::new("k", vec![]);
        assert!(empty.is_noop());
    }

    #[test]
    fn change_count_excludes_noops() {
        let plan = Plan::new(
            "k",
            vec![
                change("a", Action::Create, None, Some(json!({}))),
                change("b", Action::NoOp, None, None),
                change("c", Action::Update, Some(json!({})), Some(json!({}))),
            ],
        );
        assert_eq!(plan.change_count(), 2);
    }

    #[test]
    fn default_severity_inference() {
        assert_eq!(
            change("x", Action::Create, None, Some(json!({}))).severity,
            ChangeSeverity::Functional
        );
        assert_eq!(
            change("x", Action::Update, Some(json!({})), Some(json!({}))).severity,
            ChangeSeverity::Functional
        );
        assert_eq!(
            change("x", Action::Delete, Some(json!({})), None).severity,
            ChangeSeverity::Critical
        );
        assert_eq!(
            change("x", Action::Replace, Some(json!({})), Some(json!({}))).severity,
            ChangeSeverity::Critical
        );
        assert_eq!(
            change("x", Action::NoOp, None, None).severity,
            ChangeSeverity::Cosmetic
        );
    }
}