Skip to main content

runtime/turn/
ledger.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The turn's only door to the ledger.
7//!
8//! A turn appends two kinds of payload. One it authored itself: segment
9//! hashes, the model name, the operator's steer text — values this
10//! module computed and can vouch for. The other it merely carried: a
11//! model reply, a tool's arguments, a tool's result. The carried kind
12//! routinely holds another project's `.env` line, an API key or a
13//! passphrase, and the ledger is append-only and exportable, so a
14//! secret that lands there outlives the session, the machine, and any
15//! later decision to take it back.
16//!
17//! The two kinds therefore travel through two different functions of
18//! [`Journal`], and the carried one is scanned on the way in.
19//! [`Authored`] and [`Carried`] partition this module's event kinds,
20//! neither can name the other's, and no other spelling of [`EventKind`]
21//! reaches an [`EventDraft`] — which is what makes the scan structural
22//! rather than remembered. The run's own lines between the turn's
23//! phases are a third kind, [`RunLine`]: the run authored them, so they
24//! are not scanned, and they carry the run's address.
25//!
26//! The same variants decide when a line happened. The four lines a turn
27//! waited for - a model attempt sent, its reply whole, a tool call
28//! started, its answer - carry their own reading and cannot be built
29//! without one; every other line carries the turn's stamp. The clock
30//! those readings come from is read in this module and nowhere else in
31//! the turn (`crates/kernel/Spec.lean` §8-4, "what the envelope `t` records").
32
33use kernel::{
34    Address, AxCode, AxError, EventDraft, EventKind, EventRef, Ledger, Payload, RunId, TimeMs,
35};
36
37/// Events whose payload this module built from values it computed.
38#[derive(Debug, Clone, Copy)]
39pub(super) enum Authored {
40    PromptAssembled,
41    /// A model attempt, sent at `at`.
42    ModelCalled {
43        at: TimeMs,
44    },
45    CancelReceived,
46    SteerReceived,
47}
48
49/// A line the run writes between two of the turn's phases. It goes
50/// through the turn's journal so that it takes its place among the
51/// turn's held lines rather than ahead of them, which is what lets those
52/// lines wait for the next barrier instead of going down first
53/// (`crates/runtime/spec/Turn/Durability.lean`, `tf1Turn`).
54#[derive(Debug, Clone, Copy)]
55pub(crate) enum RunLine {
56    /// What the assembled request looks like to a prompt cache.
57    PromptShapeCompared,
58    /// The commit taken before a wave that may write.
59    CheckpointCommitted,
60}
61
62impl RunLine {
63    fn kind(self) -> EventKind {
64        match self {
65            RunLine::PromptShapeCompared => EventKind::PromptShapeCompared,
66            RunLine::CheckpointCommitted => EventKind::CheckpointCommitted,
67        }
68    }
69}
70
71/// Events whose payload came back from a provider or from a tool, and
72/// may hold a credential in plain text. Each is a moment the turn waited
73/// for, so each carries its own reading.
74#[derive(Debug, Clone, Copy)]
75pub(super) enum Carried {
76    /// A model reply, whole at `at`.
77    ModelReturned { at: TimeMs },
78    /// A tool call, started at `at`.
79    ToolCalled { at: TimeMs },
80    /// A tool call's answer, recorded at `at`.
81    ToolResult { at: TimeMs },
82}
83
84impl Authored {
85    fn kind(self) -> EventKind {
86        match self {
87            Authored::PromptAssembled => EventKind::PromptAssembled,
88            Authored::ModelCalled { .. } => EventKind::ModelCalled,
89            Authored::CancelReceived => EventKind::CancelReceived,
90            Authored::SteerReceived => EventKind::SteerReceived,
91        }
92    }
93
94    /// When the line happened: its own reading for the attempt the turn
95    /// waited on, the turn's stamp for every other line.
96    fn at(self, turn: TimeMs) -> TimeMs {
97        match self {
98            Authored::ModelCalled { at } => at,
99            Authored::PromptAssembled | Authored::CancelReceived | Authored::SteerReceived => turn,
100        }
101    }
102}
103
104impl Carried {
105    fn kind(self) -> EventKind {
106        match self {
107            Carried::ModelReturned { .. } => EventKind::ModelReturned,
108            Carried::ToolCalled { .. } => EventKind::ToolCalled,
109            Carried::ToolResult { .. } => EventKind::ToolResult,
110        }
111    }
112
113    /// When the line happened: every carried line is a moment the turn
114    /// waited for.
115    fn at(self) -> TimeMs {
116        match self {
117            Carried::ModelReturned { at }
118            | Carried::ToolCalled { at }
119            | Carried::ToolResult { at } => at,
120        }
121    }
122}
123
124/// One reading of the turn's two clocks. `us` is absent on a turn the
125/// driver gave no monotonic clock, and then no duration is recorded.
126#[derive(Debug, Clone, Copy)]
127pub(super) struct Moment {
128    pub(super) at: TimeMs,
129    pub(super) us: Option<u64>,
130}
131
132impl Moment {
133    /// Whole microseconds from `earlier` to this reading.
134    pub(super) fn since(self, earlier: Option<u64>) -> Option<u64> {
135        Some(self.us?.saturating_sub(earlier?))
136    }
137}
138
139/// The run's line of history: the lines appended but not yet durable,
140/// and the refs of the lines already durable, in the order they were
141/// appended. It lives as long as the run (`Run<Active>`) and every turn
142/// borrows it, so a turn's last lines wait for the barrier of the next
143/// turn instead of paying one of their own (runtime D36).
144///
145/// [`HeldLines::barrier`] hands every held line to the ledger in one
146/// `Ledger::append_all` and only then keeps their refs, so a ref it hands
147/// out names a durable record. A turn calls it before each outside
148/// effect - a model call, a write - and the run calls it once more before
149/// it freezes, and nowhere else (runtime D24, D36,
150/// `crates/runtime/spec/Turn/Durability.lean`).
151#[derive(Debug, Clone, PartialEq)]
152pub struct HeldLines {
153    run: RunId,
154    who: String,
155    held: Vec<EventDraft>,
156    refs: Vec<EventRef>,
157}
158
159/// A line this run appended, by its place among the run's lines: what a
160/// phase or a report keeps in place of a ref it cannot have until the
161/// next barrier.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct Entry(usize);
164
165impl HeldLines {
166    /// The lines of run `run`, written by `who`; nothing is held yet.
167    pub fn open(run: RunId, who: String) -> HeldLines {
168        HeldLines {
169            run,
170            who,
171            held: Vec::new(),
172            refs: Vec::new(),
173        }
174    }
175
176    /// Every held line, in the order it was appended, through one
177    /// `Ledger::append_all`. Holding nothing, it asks the ledger nothing.
178    ///
179    /// # Errors
180    /// Propagates the ledger's refusal; the lines it refused are dropped,
181    /// and the run ends on that error.
182    pub fn barrier(&mut self, ledger: &mut dyn Ledger) -> Result<(), AxError> {
183        if self.held.is_empty() {
184            return Ok(());
185        }
186        let echoes = ledger.append_all(std::mem::take(&mut self.held))?;
187        self.refs.extend(echoes);
188        Ok(())
189    }
190
191    /// The refs of every line already made durable, in appending order.
192    pub fn refs(&self) -> &[EventRef] {
193        &self.refs
194    }
195
196    /// The ref of a line already made durable.
197    ///
198    /// # Errors
199    /// Reports `E_INVALID_ARGS` when the line is still held: a ref for a
200    /// line no barrier has carried would name a history that may not exist.
201    pub fn durable(&self, entry: Entry) -> Result<EventRef, AxError> {
202        self.refs.get(entry.0).copied().ok_or_else(|| {
203            AxError::failure(
204                AxCode::InvalidArgs,
205                "read a run line's ref",
206                "the line is not durable yet",
207            )
208            .with_recovery(
209                "report this against runtime::turn::ledger: a ref was asked for \
210                 before the barrier that carries its line",
211            )
212        })
213    }
214
215    /// The single place an [`EventDraft`] of this run's turns is built:
216    /// the author and the place among the run's lines cannot drift
217    /// between turns.
218    fn hold(&mut self, kind: EventKind, at: TimeMs, addr: Option<Address>, data: Payload) -> Entry {
219        let entry = Entry(self.refs.len().saturating_add(self.held.len()));
220        self.held.push(EventDraft {
221            run: self.run,
222            t: at,
223            who: self.who.clone(),
224            addr,
225            kind,
226            data,
227            ig: false,
228        });
229        entry
230    }
231}
232
233/// One turn's pen on the run's lines: the turn's stamp, the clock the
234/// lines it waited for are read from, and how many secret-shaped spans
235/// it kept out of the ledger.
236///
237/// The phase data lives beside this in `Turn`, not inside it, so a
238/// phase change can move the phase out while the journal stays put and
239/// keeps appending.
240pub(super) struct Journal<'h> {
241    lines: &'h mut HeldLines,
242    t: TimeMs,
243    now: &'h mut dyn FnMut() -> Result<TimeMs, AxError>,
244    stopwatch: Option<&'h mut dyn FnMut() -> u64>,
245    redacted: u32,
246}
247
248impl std::fmt::Debug for Journal<'_> {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        f.debug_struct("Journal")
251            .field("lines", &self.lines)
252            .field("t", &self.t)
253            .field("redacted", &self.redacted)
254            .finish_non_exhaustive()
255    }
256}
257
258impl<'h> Journal<'h> {
259    pub(super) fn open(
260        lines: &'h mut HeldLines,
261        t: TimeMs,
262        now: &'h mut dyn FnMut() -> Result<TimeMs, AxError>,
263    ) -> Journal<'h> {
264        Journal {
265            lines,
266            t,
267            now,
268            stopwatch: None,
269            redacted: 0,
270        }
271    }
272
273    /// Times the turn's waits on `monotonic_us` from here on: microseconds
274    /// on a clock that only moves forward, from an origin of the
275    /// driver's choosing (kernel D20).
276    pub(super) fn time_with(&mut self, monotonic_us: &'h mut dyn FnMut() -> u64) {
277        self.stopwatch = Some(monotonic_us);
278    }
279
280    /// How many secret-shaped spans this turn replaced so far.
281    pub(super) fn redacted(&self) -> u32 {
282        self.redacted
283    }
284
285    /// The run's lines this turn writes to.
286    pub(super) fn lines(&mut self) -> &mut HeldLines {
287        self.lines
288    }
289
290    /// The barrier a turn pays before an outside effect
291    /// ([`HeldLines::barrier`]).
292    ///
293    /// # Errors
294    /// Propagates the ledger's refusal.
295    pub(super) fn barrier(&mut self, ledger: &mut dyn Ledger) -> Result<(), AxError> {
296        self.lines.barrier(ledger)
297    }
298
299    /// Appends an event the turn authored, verbatim. The line is held
300    /// until the next [`Journal::barrier`].
301    pub(super) fn append_authored(&mut self, event: Authored, data: Payload) -> Entry {
302        let at = event.at(self.t);
303        self.append(event.kind(), at, None, data)
304    }
305
306    /// Appends a line the run wrote at `addr`, at the turn's stamp. The
307    /// line is held until the next [`Journal::barrier`].
308    pub(super) fn append_run_line(&mut self, line: RunLine, addr: Address, data: Payload) -> Entry {
309        self.append(line.kind(), self.t, Some(addr), data)
310    }
311
312    /// Appends an event the turn carried, with every secret-shaped span
313    /// replaced by a `secret:redacted/<b3-16>` marker first. The line is
314    /// held until the next [`Journal::barrier`].
315    ///
316    /// The window already holds the blocks the next request is built
317    /// from, so replacing here cannot break a thinking block's
318    /// signature or make the conversation stop making sense: history
319    /// and context are two sinks, and only this one is permanent.
320    ///
321    /// # Errors
322    /// Reports `E_INVALID_ARGS` when the scanned map is not a valid
323    /// payload.
324    pub(super) fn append_redacted(
325        &mut self,
326        event: Carried,
327        data: Payload,
328    ) -> Result<Entry, AxError> {
329        let (scanned, hits) = crate::redact::redact(data.into_map());
330        self.redacted = self.redacted.saturating_add(hits);
331        Ok(self.append(event.kind(), event.at(), None, Payload::new(scanned)?))
332    }
333
334    /// The turn's stamp: the time every line of this turn carries except
335    /// the four it waited for, and the time a wave's tools are admitted at.
336    pub(super) fn stamp(&self) -> TimeMs {
337        self.t
338    }
339
340    /// One reading of both clocks: the moment a line carries, and the
341    /// monotonic microseconds a duration is the difference of.
342    ///
343    /// # Errors
344    /// Propagates the clock's failure, such as a clock past `u64`.
345    pub(super) fn read_moment(&mut self) -> Result<Moment, AxError> {
346        Ok(Moment {
347            at: (self.now)()?,
348            us: self.stopwatch.as_mut().map(|read| read()),
349        })
350    }
351
352    fn append(
353        &mut self,
354        kind: EventKind,
355        at: TimeMs,
356        addr: Option<Address>,
357        data: Payload,
358    ) -> Entry {
359        self.lines.hold(kind, at, addr, data)
360    }
361}
362
363#[cfg(test)]
364#[allow(clippy::unwrap_used, reason = "test code")]
365mod a_ref_before_its_barrier {
366    use super::*;
367
368    /// A ref asked for before the barrier names a history that may not
369    /// exist; the refusal is one sentence.
370    #[test]
371    fn is_refused_in_one_sentence() {
372        let refused = HeldLines::open(RunId::from_bytes([7; 16]), "resident@sim.1".to_owned())
373            .durable(Entry(0))
374            .unwrap_err();
375        assert_eq!(
376            refused.recovery(),
377            "report this against runtime::turn::ledger: a ref was asked for before the barrier that carries its line"
378        );
379    }
380}