Skip to main content

bock_ai/
decision.rs

1//! Decision manifest record types (§17.4).
2//!
3//! Per the 2026-04-22 spec amendment, decisions are split into two
4//! populations:
5//!
6//! * **Build decisions** — produced during compilation (codegen, repair,
7//!   optimization, rule application). Stable artifacts of the build,
8//!   committed to version control under `.bock/decisions/build/`.
9//! * **Runtime decisions** — produced during execution (adaptive effect
10//!   handler selection, §10.8). Environment-local, not committed,
11//!   stored under `.bock/decisions/runtime/`.
12//!
13//! [`DecisionType::scope`] routes each variant to the correct
14//! manifest. New variants must declare their scope explicitly so the
15//! routing stays exhaustive.
16
17use std::path::PathBuf;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22/// A single decision recorded in the manifest.
23///
24/// `id` is the content hash of the originating request — the same value
25/// the [`AiCache`](crate::cache::AiCache) uses for its key — so that
26/// pinned decisions can be replayed deterministically.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct Decision {
29    /// Content hash of the originating request.
30    pub id: String,
31    /// Source module the decision applies to.
32    pub module: PathBuf,
33    /// Target language identifier (e.g., `"rust"`). `None` for runtime
34    /// decisions which are target-independent.
35    pub target: Option<String>,
36    /// What kind of decision this is — drives manifest routing via
37    /// [`DecisionType::scope`].
38    pub decision_type: DecisionType,
39    /// Selected choice (e.g., `"tokio"`, generated code snippet, or
40    /// strategy identifier).
41    pub choice: String,
42    /// Alternatives considered but not chosen.
43    pub alternatives: Vec<String>,
44    /// Optional free-form reasoning supplied by the provider.
45    pub reasoning: Option<String>,
46    /// Stable provider/model identifier (e.g., `"anthropic:claude-opus"`).
47    pub model_id: String,
48    /// Confidence in the choice, `0.0..=1.0` (§17.4).
49    pub confidence: f64,
50    /// Whether this decision is pinned (replayed identically on rebuild).
51    pub pinned: bool,
52    /// If pinned, why — `"cache-replay"`, `"auto-pin"`, `"manual"`, etc.
53    pub pin_reason: Option<String>,
54    /// When the decision was pinned, if at all.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub pinned_at: Option<DateTime<Utc>>,
57    /// Who pinned the decision (free-form identifier — username or CI tag).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub pinned_by: Option<String>,
60    /// If this entry was superseded by a later promotion, the id of the
61    /// successor decision in the build manifest. Set on a runtime decision
62    /// after `bock override --promote` copies it into the build scope.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub superseded_by: Option<String>,
65    /// When the decision was recorded.
66    pub timestamp: DateTime<Utc>,
67}
68
69/// Categories of decisions recorded by the compiler and runtime.
70///
71/// Each variant has a fixed [`scope`](Self::scope), so manifest routing
72/// is exhaustive and adding a variant forces the author to pick a
73/// scope explicitly.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum DecisionType {
77    // ── Build-scope ──────────────────────────────────────────────────
78    /// Tier 1 generation (§17.2).
79    Codegen,
80    /// Repair pass following a target-compiler failure (§17.7).
81    Repair,
82    /// Tier 3 optimization pass (§17.2).
83    Optimize,
84    /// Application of a Tier 2 rule from the local rule cache (§17.7).
85    RuleApplied,
86
87    /// A runtime adaptive selection that has been promoted to a
88    /// build-time pin via `bock override --promote` (§10.8). Routes
89    /// to the build manifest so subsequent production builds replay
90    /// the recovery strategy deterministically.
91    HandlerChoice,
92
93    // ── Runtime-scope ────────────────────────────────────────────────
94    /// Adaptive recovery strategy selection (§10.8).
95    AdaptiveRecovery,
96}
97
98impl DecisionType {
99    /// Routing scope for this decision type.
100    ///
101    /// Exhaustive over the variants — adding a new variant is a
102    /// compile error until its scope is named here.
103    #[must_use]
104    pub fn scope(&self) -> ManifestScope {
105        match self {
106            Self::AdaptiveRecovery => ManifestScope::Runtime,
107            Self::Codegen
108            | Self::Repair
109            | Self::Optimize
110            | Self::RuleApplied
111            | Self::HandlerChoice => ManifestScope::Build,
112        }
113    }
114}
115
116/// Which manifest a decision belongs to.
117///
118/// Matches the directory split under `.bock/decisions/`.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ManifestScope {
122    /// Build-time decisions — committed to VCS.
123    Build,
124    /// Runtime decisions — local only.
125    Runtime,
126}
127
128impl ManifestScope {
129    /// Subdirectory name within `.bock/decisions/` for this scope.
130    #[must_use]
131    pub fn dir_name(self) -> &'static str {
132        match self {
133            Self::Build => "build",
134            Self::Runtime => "runtime",
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn codegen_routes_to_build() {
145        assert_eq!(DecisionType::Codegen.scope(), ManifestScope::Build);
146        assert_eq!(DecisionType::Repair.scope(), ManifestScope::Build);
147        assert_eq!(DecisionType::Optimize.scope(), ManifestScope::Build);
148        assert_eq!(DecisionType::RuleApplied.scope(), ManifestScope::Build);
149        assert_eq!(DecisionType::HandlerChoice.scope(), ManifestScope::Build);
150    }
151
152    #[test]
153    fn adaptive_recovery_routes_to_runtime() {
154        assert_eq!(
155            DecisionType::AdaptiveRecovery.scope(),
156            ManifestScope::Runtime
157        );
158    }
159
160    #[test]
161    fn manifest_scope_dir_names() {
162        assert_eq!(ManifestScope::Build.dir_name(), "build");
163        assert_eq!(ManifestScope::Runtime.dir_name(), "runtime");
164    }
165
166    #[test]
167    fn decision_round_trips_through_json() {
168        let d = Decision {
169            id: "abc123".into(),
170            module: PathBuf::from("src/lib.bock"),
171            target: Some("rust".into()),
172            decision_type: DecisionType::Codegen,
173            choice: "tokio".into(),
174            alternatives: vec!["async-std".into(), "smol".into()],
175            reasoning: Some("axum requires tokio".into()),
176            model_id: "anthropic:claude-opus".into(),
177            confidence: 0.92,
178            pinned: false,
179            pin_reason: None,
180            pinned_at: None,
181            pinned_by: None,
182            superseded_by: None,
183            timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
184        };
185        let s = serde_json::to_string(&d).expect("serialize");
186        let d2: Decision = serde_json::from_str(&s).expect("deserialize");
187        assert_eq!(d, d2);
188    }
189
190    #[test]
191    fn pin_metadata_round_trips() {
192        let d = Decision {
193            id: "abc123".into(),
194            module: PathBuf::from("src/lib.bock"),
195            target: Some("rust".into()),
196            decision_type: DecisionType::Codegen,
197            choice: "tokio".into(),
198            alternatives: vec![],
199            reasoning: None,
200            model_id: "anthropic:claude-opus".into(),
201            confidence: 0.92,
202            pinned: true,
203            pin_reason: Some("reviewed by @alice 2026-04-22".into()),
204            pinned_at: Some(DateTime::<Utc>::from_timestamp(1_745_000_000, 0).unwrap()),
205            pinned_by: Some("alice".into()),
206            superseded_by: None,
207            timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
208        };
209        let s = serde_json::to_string(&d).expect("serialize");
210        let d2: Decision = serde_json::from_str(&s).expect("deserialize");
211        assert_eq!(d, d2);
212    }
213
214    #[test]
215    fn missing_optional_fields_deserialize_as_none() {
216        // Older manifest files written before the pin-metadata fields
217        // existed must still parse — the serde defaults cover the gap.
218        let json = r#"{
219            "id": "x",
220            "module": "src/lib.bock",
221            "target": "rust",
222            "decision_type": "codegen",
223            "choice": "tokio",
224            "alternatives": [],
225            "reasoning": null,
226            "model_id": "stub:stub",
227            "confidence": 1.0,
228            "pinned": false,
229            "pin_reason": null,
230            "timestamp": "2026-04-22T10:00:00Z"
231        }"#;
232        let d: Decision = serde_json::from_str(json).expect("backward-compatible parse");
233        assert!(d.pinned_at.is_none());
234        assert!(d.pinned_by.is_none());
235        assert!(d.superseded_by.is_none());
236    }
237}