Skip to main content

deepstrike_core/runtime/kernel/wire/
binding.rs

1//! Binding-safe façade for the canonical durable transition protocol.
2//!
3//! [`CanonicalKernel`] is the public pair that hosts need: the transaction decides whether an
4//! envelope is accepted, while the driver decides what the accepted input means. Keeping the pair
5//! behind one handle makes two invalid call sequences unavailable to bindings:
6//!
7//! * a host cannot call the semantic planner without first preparing a durable record;
8//! * a host cannot commit the transaction without also advancing the driver's committed fold.
9//!
10//! There is intentionally no `step` method. Production callers must prepare, CAS-append the exact
11//! core-produced record bytes, then commit. Checkpoint restore mutates the handle in place so a
12//! binding object keeps its identity across a CAS rebuild.
13
14use super::checkpoint::{
15    CheckpointCandidate, KernelCheckpoint, LogicalKernelState, LogicalStateProjection,
16};
17use super::config::ConfigDefaults;
18use super::driver::{CanonicalOperationDriver, PlannedStep};
19use super::effect::{Digest, KernelEffect};
20use super::envelope::{
21    OperationLifecycle, WireEnvelope, WireRejection, WireRejectionKind, decode_envelope_json,
22};
23use super::fault::{
24    KernelFault, KernelFaultCode, KernelPreparation, PrepareToken, RejectedTransition,
25};
26use super::record::{KernelRecord, RecordPreparation};
27use super::restore::{RestoreCost, restore_operation};
28use super::terminal::KernelTerminal;
29use super::transaction::{
30    CheckpointBoundary, CommittedTransition, DurableHead, InMemoryRecordIndex, KernelTransaction,
31    TailUsage,
32};
33
34type CanonicalTransaction = KernelTransaction<PlannedStep, InMemoryRecordIndex>;
35
36enum DriverRestorePoint {
37    Fresh,
38    Logical(Box<LogicalKernelState>),
39}
40
41/// One canonical operation driven exclusively through the durable transition protocol.
42///
43/// The type is deliberately not `Clone`: duplicating a live candidate would make two handles able
44/// to commit the same prepare token. Rebuild instead uses [`Self::restore`] or
45/// [`Self::restore_bytes`], both of which replace this handle's internals in place.
46pub struct CanonicalKernel {
47    defaults: ConfigDefaults,
48    transaction: CanonicalTransaction,
49    driver: CanonicalOperationDriver,
50    before_candidate: Option<DriverRestorePoint>,
51}
52
53impl std::fmt::Debug for CanonicalKernel {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("CanonicalKernel")
56            .field("operation_id", &self.transaction.operation_id())
57            .field("head", &self.transaction.head())
58            .field("lifecycle", &self.transaction.lifecycle())
59            .field("has_candidate", &self.transaction.has_candidate())
60            .finish()
61    }
62}
63
64impl Default for CanonicalKernel {
65    fn default() -> Self {
66        Self::new(ConfigDefaults::default())
67    }
68}
69
70impl CanonicalKernel {
71    /// Construct an empty operation under explicit compile-time defaults/bootstrap ceilings.
72    pub fn new(defaults: ConfigDefaults) -> Self {
73        Self {
74            transaction: KernelTransaction::new(defaults.clone(), InMemoryRecordIndex::new()),
75            driver: CanonicalOperationDriver::new(),
76            defaults,
77            before_candidate: None,
78        }
79    }
80
81    /// Strict JSON boundary for dynamic-language bindings.
82    ///
83    /// Decode failures use the same closed `Rejected` arm as typed policy/lifecycle failures; a
84    /// malformed payload never escapes as one language's parser exception.
85    pub fn prepare_json(&mut self, input_json: &str) -> RecordPreparation<PlannedStep> {
86        match decode_envelope_json(input_json, &self.defaults.bootstrap_limits) {
87            Ok(envelope) => self.prepare(&envelope),
88            Err(rejection) => KernelPreparation::Rejected(RejectedTransition {
89                fault: rejection_fault(rejection),
90            }),
91        }
92    }
93
94    /// Plan one typed canonical envelope and stage its core-produced record.
95    pub fn prepare(&mut self, envelope: &WireEnvelope) -> RecordPreparation<PlannedStep> {
96        // Preserve an existing candidate. `KernelTransaction::prepare` will return the structured
97        // transaction-conflict rejection before invoking this closure.
98        if self.before_candidate.is_some() {
99            let Self {
100                transaction,
101                driver,
102                ..
103            } = self;
104            return transaction.prepare(envelope, |context| driver.plan(context));
105        }
106
107        let restore_point = match self.capture_driver_restore_point() {
108            Ok(restore_point) => restore_point,
109            Err(fault) => {
110                return KernelPreparation::Rejected(RejectedTransition { fault });
111            }
112        };
113        let preparation = {
114            let Self {
115                transaction,
116                driver,
117                ..
118            } = self;
119            transaction.prepare(envelope, |context| driver.plan(context))
120        };
121
122        if matches!(preparation, KernelPreparation::Prepared(_)) {
123            self.before_candidate = Some(restore_point);
124            return preparation;
125        }
126
127        // A planner can advance the semantic engine before a later record/tail screen rejects the
128        // preparation. The transaction promises zero mutation for this arm, so restore the driver
129        // to make the promise true for the pair as well.
130        if let Err(fault) = self.restore_driver(restore_point) {
131            return KernelPreparation::Rejected(RejectedTransition { fault });
132        }
133        preparation
134    }
135
136    /// Complete a transition after the host durably appended the candidate record.
137    pub fn commit(
138        &mut self,
139        token: &PrepareToken,
140        appended_head: &Digest,
141    ) -> Result<CommittedTransition<PlannedStep>, KernelFault> {
142        let committed = self.transaction.commit(token, appended_head)?;
143        let step_seq = committed.step_seq;
144        self.before_candidate = None;
145        self.driver.note_committed(step_seq)?;
146        Ok(committed)
147    }
148
149    /// Discard a candidate that did not reach the journal and restore the semantic driver.
150    pub fn abort(&mut self, token: &PrepareToken) -> Result<KernelRecord, KernelFault> {
151        let record = self.transaction.abort(token)?;
152        let restore_point = self.before_candidate.take().ok_or_else(|| {
153            KernelFault::new(
154                KernelFaultCode::TransactionConflict,
155                "the transaction aborted a candidate but the canonical driver has no restore point",
156            )
157        })?;
158        self.restore_driver(restore_point)?;
159        Ok(record)
160    }
161
162    /// Rebuild this exact handle from a verified checkpoint plus records above it.
163    ///
164    /// `checkpoint = None` means the records are the whole retained journal and the fold starts at
165    /// genesis. With a checkpoint, `records` are strictly above `through_step_seq`.
166    pub fn restore(
167        &mut self,
168        checkpoint: Option<&KernelCheckpoint>,
169        records: &[KernelRecord],
170    ) -> Result<RestoreCost, KernelFault> {
171        let restored = restore_operation(
172            checkpoint,
173            records,
174            self.defaults.clone(),
175            InMemoryRecordIndex::from_records(records),
176        )?;
177        let cost = restored.cost;
178        self.transaction = restored.transaction;
179        self.driver = restored.driver;
180        self.before_candidate = None;
181        Ok(cost)
182    }
183
184    /// Binding boundary for native checkpoint/record byte containers.
185    pub fn restore_bytes(
186        &mut self,
187        checkpoint_bytes: Option<&[u8]>,
188        record_bytes: &[Vec<u8>],
189    ) -> Result<RestoreCost, KernelFault> {
190        let checkpoint = checkpoint_bytes
191            .map(KernelCheckpoint::from_checkpoint_bytes)
192            .transpose()
193            .map_err(|error| error.fault())?;
194        let records = record_bytes
195            .iter()
196            .map(|bytes| {
197                KernelRecord::from_record_bytes(bytes)
198                    .map_err(|error| KernelFault::new(error.code(), error.message().to_string()))
199            })
200            .collect::<Result<Vec<_>, _>>()?;
201        self.restore(checkpoint.as_ref(), &records)
202    }
203
204    /// Produce a full-state checkpoint candidate over the current durable head.
205    pub fn checkpoint_candidate(&self) -> Result<CheckpointCandidate, KernelFault> {
206        self.transaction
207            .checkpoint_candidate(self.driver.project_logical_state())
208    }
209
210    /// Produce the incremental checkpoint form over a previously captured logical base.
211    pub fn checkpoint_rebase(
212        &self,
213        base: &KernelCheckpoint,
214    ) -> Result<CheckpointCandidate, KernelFault> {
215        self.transaction
216            .checkpoint_rebase(&base.boundary(), base.logical_state().clone())
217    }
218
219    /// Close the retention boundary after the host durably acknowledged a checkpoint install.
220    pub fn note_checkpoint_acked(
221        &mut self,
222        boundary: &CheckpointBoundary,
223    ) -> Result<TailUsage, KernelFault> {
224        self.transaction.note_checkpoint_acked(boundary)
225    }
226
227    pub fn head(&self) -> Option<DurableHead> {
228        self.transaction.head()
229    }
230
231    pub fn lifecycle(&self) -> OperationLifecycle {
232        self.transaction.lifecycle()
233    }
234
235    pub fn pending_effects(&self) -> impl Iterator<Item = &KernelEffect> {
236        self.transaction.pending_effects()
237    }
238
239    /// [`Self::pending_effects`] in publication order — the order a host consumes a
240    /// multi-effect step in (see [`KernelTransaction::pending_effects_in_order`]).
241    pub fn pending_effects_in_order(&self) -> Vec<&KernelEffect> {
242        self.transaction.pending_effects_in_order()
243    }
244
245    /// The single host-facing current-action projection.  The transaction owns publication
246    /// ordering; this method only delegates the ordered view to the pure projection module.
247    pub fn current_projection(
248        &self,
249    ) -> Result<super::projection::CurrentProjection, super::projection::ProjectionError> {
250        super::projection::project_current_pending_action(
251            self.transaction.terminal(),
252            self.transaction.pending_effects_in_order(),
253        )
254    }
255
256    pub fn terminal(&self) -> Option<&KernelTerminal> {
257        self.transaction.terminal()
258    }
259
260    /// Return the live kernel-issued attempt for a child task, including after checkpoint restore.
261    pub fn attempt_id(&self, task_id: &str) -> Option<super::scalar::AttemptId> {
262        self.driver.attempt_id(task_id).cloned()
263    }
264
265    /// Current scheduler turn, restored from the canonical journal/checkpoint state.
266    pub fn turn(&self) -> u32 {
267        self.driver.engine().map_or(0, |engine| engine.turn)
268    }
269
270    /// Recovery replay budget in bytes, when the operation has been configured.
271    pub fn recovery_content_bytes(&self) -> Option<usize> {
272        self.driver.engine().map(|engine| {
273            let tokens = engine
274                .ctx
275                .config
276                .recovery_content_tokens(engine.ctx.max_tokens);
277            engine.ctx.engine.token_budget_to_bytes(tokens)
278        })
279    }
280
281    /// Task-state references that context pressure must keep resident.
282    pub fn preserved_refs(&self) -> Vec<String> {
283        self.driver
284            .engine()
285            .map(|engine| engine.ctx.partitions.task_state.preserved_refs.clone())
286            .unwrap_or_default()
287    }
288
289    /// Count text using the configured canonical context token engine.
290    pub fn count_tokens(&self, text: &str) -> Option<u32> {
291        self.driver
292            .engine()
293            .map(|engine| engine.ctx.engine.count(text))
294    }
295
296    /// Cumulative kernel-owned child spawn count for this operation.
297    pub fn local_subagents_spawned(&self) -> u32 {
298        self.driver
299            .engine()
300            .map_or(0, |engine| engine.local_subagents_spawned())
301    }
302
303    /// Messages added to the canonical operation history.
304    pub fn new_messages(&self) -> Vec<crate::types::message::Message> {
305        self.driver
306            .engine()
307            .map(|engine| engine.drain_new_messages())
308            .unwrap_or_default()
309    }
310
311    pub fn poison(&self) -> Option<&KernelFault> {
312        self.transaction.poison().or_else(|| self.driver.poison())
313    }
314
315    fn capture_driver_restore_point(&self) -> Result<DriverRestorePoint, KernelFault> {
316        if self.transaction.config().is_none() {
317            return Ok(DriverRestorePoint::Fresh);
318        }
319        let LogicalStateProjection {
320            root_kind,
321            focus,
322            syscall,
323            scheduler,
324            context_vm,
325        } = self.driver.project_logical_state();
326        let transition = self
327            .transaction
328            .transition_state_for_restore(root_kind, focus)?;
329        Ok(DriverRestorePoint::Logical(Box::new(LogicalKernelState {
330            transition,
331            syscall,
332            scheduler,
333            context_vm,
334        })))
335    }
336
337    fn restore_driver(&mut self, restore_point: DriverRestorePoint) -> Result<(), KernelFault> {
338        self.driver = match restore_point {
339            DriverRestorePoint::Fresh => CanonicalOperationDriver::new(),
340            DriverRestorePoint::Logical(state) => CanonicalOperationDriver::restore_logical_state(
341                &state.transition.resolved_config,
342                &state,
343            )?,
344        };
345        Ok(())
346    }
347}
348
349fn rejection_fault(rejection: WireRejection) -> KernelFault {
350    let code = match rejection.kind {
351        WireRejectionKind::PolicyViolation => KernelFaultCode::InvalidConfig,
352        _ => KernelFaultCode::MalformedEnvelope,
353    };
354    KernelFault::new(code, rejection.message)
355}
356
357#[cfg(test)]
358mod tests {
359    use serde_json::Value;
360
361    use super::CanonicalKernel;
362    use crate::runtime::kernel::wire::{
363        KernelFaultCode, KernelPreparation, PrepareToken, WireEnvelope,
364    };
365
366    fn golden_agent_root() -> Value {
367        serde_json::from_str(include_str!(
368            "../../../../../../tests/fixtures/kernel-wire/golden_lifecycle_agent_root.json"
369        ))
370        .expect("golden fixture")
371    }
372
373    fn commit_input(kernel: &mut CanonicalKernel, input: &str) {
374        let prepared = kernel.prepare_json(input);
375        let KernelPreparation::Prepared(prepared) = prepared else {
376            panic!("expected prepared transition");
377        };
378        kernel
379            .commit(&prepared.token, prepared.record.record_digest())
380            .expect("commit");
381    }
382
383    #[test]
384    fn canonical_kernel_produces_the_shared_genesis_record() {
385        let fixture = golden_agent_root();
386        let mut kernel = CanonicalKernel::default();
387        let preparation = kernel.prepare_json(&fixture["links"][0]["envelope"].to_string());
388        let KernelPreparation::Prepared(prepared) = preparation else {
389            panic!("golden envelope must prepare");
390        };
391
392        assert_eq!(
393            prepared.record.record_digest().as_str(),
394            fixture["genesis_digest"].as_str().unwrap()
395        );
396        assert_eq!(
397            std::str::from_utf8(prepared.record.record_bytes().as_slice()).unwrap(),
398            serde_json::to_string(&fixture["links"][0]["record"]).unwrap()
399        );
400    }
401
402    #[test]
403    fn abort_restores_the_driver_before_the_next_prepare() {
404        let fixture = golden_agent_root();
405        let mut kernel = CanonicalKernel::default();
406        commit_input(&mut kernel, &fixture["links"][0]["envelope"].to_string());
407
408        let start = fixture["links"][1]["envelope"].clone();
409        let first = kernel.prepare_json(&start.to_string());
410        let KernelPreparation::Prepared(first) = first else {
411            panic!("start must prepare");
412        };
413        let first_digest = first.record.record_digest().clone();
414        kernel.abort(&first.token).expect("abort before append");
415
416        let second = kernel.prepare_json(&start.to_string());
417        let KernelPreparation::Prepared(second) = second else {
418            panic!("the same input must prepare after abort");
419        };
420        assert_eq!(second.record.record_digest(), &first_digest);
421    }
422
423    #[test]
424    fn malformed_and_unknown_envelopes_are_structured_rejections() {
425        let mut kernel = CanonicalKernel::default();
426        let malformed = kernel.prepare_json("{");
427        assert_eq!(
428            malformed.fault().map(|fault| fault.code),
429            Some(KernelFaultCode::MalformedEnvelope)
430        );
431
432        let fixture = golden_agent_root();
433        let mut unknown = fixture["links"][0]["envelope"].clone();
434        unknown
435            .as_object_mut()
436            .unwrap()
437            .insert("session_id".to_string(), Value::String("host-only".into()));
438        let rejected = kernel.prepare_json(&unknown.to_string());
439        assert_eq!(
440            rejected.fault().map(|fault| fault.code),
441            Some(KernelFaultCode::MalformedEnvelope)
442        );
443    }
444
445    #[test]
446    fn restore_replaces_the_same_typed_handle() {
447        let fixture = golden_agent_root();
448        let mut kernel = CanonicalKernel::default();
449        commit_input(&mut kernel, &fixture["links"][0]["envelope"].to_string());
450        let checkpoint = kernel
451            .checkpoint_candidate()
452            .expect("configured operation checkpoints")
453            .decode()
454            .expect("checkpoint verifies");
455
456        let start: WireEnvelope =
457            serde_json::from_value(fixture["links"][1]["envelope"].clone()).unwrap();
458        let KernelPreparation::Prepared(prepared) = kernel.prepare(&start) else {
459            panic!("start prepares");
460        };
461        let post_checkpoint_record = prepared.record.clone();
462        let expected_head = prepared.record.record_digest().clone();
463        kernel
464            .commit(&prepared.token, prepared.record.record_digest())
465            .unwrap();
466
467        let handle_address = std::ptr::addr_of!(kernel);
468        kernel
469            .restore(Some(&checkpoint), &[post_checkpoint_record])
470            .expect("restore");
471        assert_eq!(std::ptr::addr_of!(kernel), handle_address);
472        assert_eq!(kernel.head().unwrap().digest, expected_head);
473
474        let no_candidate = PrepareToken::new("no-candidate").unwrap();
475        assert!(kernel.abort(&no_candidate).is_err());
476    }
477}