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 lines a turn
27//! waited for - a model attempt sent, the wait it took at its endpoint's
28//! gate, its reply whole, a tool call started, its answer - carry their
29//! own reading and cannot be built 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 /// The wait an attempt took at its endpoint's gate, read when the
79 /// attempt came back at `at`.
80 PrefixHeld { at: TimeMs },
81 /// A tool call, started at `at`.
82 ToolCalled { at: TimeMs },
83 /// A tool call's answer, recorded at `at`.
84 ToolResult { at: TimeMs },
85}
86
87impl Authored {
88 fn kind(self) -> EventKind {
89 match self {
90 Authored::PromptAssembled => EventKind::PromptAssembled,
91 Authored::ModelCalled { .. } => EventKind::ModelCalled,
92 Authored::CancelReceived => EventKind::CancelReceived,
93 Authored::SteerReceived => EventKind::SteerReceived,
94 }
95 }
96
97 /// When the line happened: its own reading for the attempt the turn
98 /// waited on, the turn's stamp for every other line.
99 fn at(self, turn: TimeMs) -> TimeMs {
100 match self {
101 Authored::ModelCalled { at } => at,
102 Authored::PromptAssembled | Authored::CancelReceived | Authored::SteerReceived => turn,
103 }
104 }
105}
106
107impl Carried {
108 fn kind(self) -> EventKind {
109 match self {
110 Carried::ModelReturned { .. } => EventKind::ModelReturned,
111 Carried::PrefixHeld { .. } => EventKind::PrefixHeld,
112 Carried::ToolCalled { .. } => EventKind::ToolCalled,
113 Carried::ToolResult { .. } => EventKind::ToolResult,
114 }
115 }
116
117 /// When the line happened: every carried line is a moment the turn
118 /// waited for.
119 fn at(self) -> TimeMs {
120 match self {
121 Carried::ModelReturned { at }
122 | Carried::PrefixHeld { at }
123 | Carried::ToolCalled { at }
124 | Carried::ToolResult { at } => at,
125 }
126 }
127}
128
129/// One reading of the turn's two clocks. `us` is absent on a turn the
130/// driver gave no monotonic clock, and then no duration is recorded.
131#[derive(Debug, Clone, Copy)]
132pub(super) struct Moment {
133 pub(super) at: TimeMs,
134 pub(super) us: Option<u64>,
135}
136
137impl Moment {
138 /// Whole microseconds from `earlier` to this reading.
139 pub(super) fn since(self, earlier: Option<u64>) -> Option<u64> {
140 Some(self.us?.saturating_sub(earlier?))
141 }
142}
143
144/// The run's line of history: the lines appended but not yet durable,
145/// and the refs of the lines already durable, in the order they were
146/// appended. It lives as long as the run (`Run<Active>`) and every turn
147/// borrows it, so a turn's last lines wait for the barrier of the next
148/// turn instead of paying one of their own (runtime D36).
149///
150/// [`HeldLines::barrier`] hands every held line to the ledger in one
151/// `Ledger::append_all` and only then keeps their refs, so a ref it hands
152/// out names a durable record. A turn calls it before each outside
153/// effect - a model call, a write - and the run calls it once more before
154/// it freezes, and nowhere else (runtime D24, D36,
155/// `crates/runtime/spec/Turn/Durability.lean`).
156#[derive(Debug, Clone, PartialEq)]
157pub struct HeldLines {
158 run: RunId,
159 who: String,
160 held: Vec<EventDraft>,
161 refs: Vec<EventRef>,
162}
163
164/// A line this run appended, by its place among the run's lines: what a
165/// phase or a report keeps in place of a ref it cannot have until the
166/// next barrier.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub struct Entry(usize);
169
170impl HeldLines {
171 /// The lines of run `run`, written by `who`; nothing is held yet.
172 pub fn open(run: RunId, who: String) -> HeldLines {
173 HeldLines {
174 run,
175 who,
176 held: Vec::new(),
177 refs: Vec::new(),
178 }
179 }
180
181 /// Every held line, in the order it was appended, through one
182 /// `Ledger::append_all`. Holding nothing, it asks the ledger nothing.
183 ///
184 /// # Errors
185 /// Propagates the ledger's refusal; the lines it refused are dropped,
186 /// and the run ends on that error.
187 pub fn barrier(&mut self, ledger: &mut dyn Ledger) -> Result<(), AxError> {
188 if self.held.is_empty() {
189 return Ok(());
190 }
191 let echoes = ledger.append_all(std::mem::take(&mut self.held))?;
192 self.refs.extend(echoes);
193 Ok(())
194 }
195
196 /// The refs of every line already made durable, in appending order.
197 pub fn refs(&self) -> &[EventRef] {
198 &self.refs
199 }
200
201 /// The ref of a line already made durable.
202 ///
203 /// # Errors
204 /// Reports `E_INVALID_ARGS` when the line is still held: a ref for a
205 /// line no barrier has carried would name a history that may not exist.
206 pub fn durable(&self, entry: Entry) -> Result<EventRef, AxError> {
207 self.refs.get(entry.0).copied().ok_or_else(|| {
208 AxError::failure(
209 AxCode::InvalidArgs,
210 "read a run line's ref",
211 "the line is not durable yet",
212 )
213 .with_recovery(
214 "report this against runtime::turn::ledger: a ref was asked for \
215 before the barrier that carries its line",
216 )
217 })
218 }
219
220 /// The single place an [`EventDraft`] of this run's turns is built:
221 /// the author and the place among the run's lines cannot drift
222 /// between turns.
223 fn hold(&mut self, kind: EventKind, at: TimeMs, addr: Option<Address>, data: Payload) -> Entry {
224 let entry = Entry(self.refs.len().saturating_add(self.held.len()));
225 self.held.push(EventDraft {
226 run: self.run,
227 t: at,
228 who: self.who.clone(),
229 addr,
230 kind,
231 data,
232 ig: false,
233 });
234 entry
235 }
236}
237
238/// One turn's pen on the run's lines: the turn's stamp, the clock the
239/// lines it waited for are read from, and how many secret-shaped spans
240/// it kept out of the ledger.
241///
242/// The phase data lives beside this in `Turn`, not inside it, so a
243/// phase change can move the phase out while the journal stays put and
244/// keeps appending.
245pub(super) struct Journal<'h> {
246 lines: &'h mut HeldLines,
247 t: TimeMs,
248 now: &'h mut dyn FnMut() -> Result<TimeMs, AxError>,
249 stopwatch: Option<&'h mut dyn FnMut() -> u64>,
250 redacted: u32,
251}
252
253impl std::fmt::Debug for Journal<'_> {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 f.debug_struct("Journal")
256 .field("lines", &self.lines)
257 .field("t", &self.t)
258 .field("redacted", &self.redacted)
259 .finish_non_exhaustive()
260 }
261}
262
263impl<'h> Journal<'h> {
264 pub(super) fn open(
265 lines: &'h mut HeldLines,
266 t: TimeMs,
267 now: &'h mut dyn FnMut() -> Result<TimeMs, AxError>,
268 ) -> Journal<'h> {
269 Journal {
270 lines,
271 t,
272 now,
273 stopwatch: None,
274 redacted: 0,
275 }
276 }
277
278 /// Times the turn's waits on `monotonic_us` from here on: microseconds
279 /// on a clock that only moves forward, from an origin of the
280 /// driver's choosing (kernel D20).
281 pub(super) fn time_with(&mut self, monotonic_us: &'h mut dyn FnMut() -> u64) {
282 self.stopwatch = Some(monotonic_us);
283 }
284
285 /// How many secret-shaped spans this turn replaced so far.
286 pub(super) fn redacted(&self) -> u32 {
287 self.redacted
288 }
289
290 /// The run's lines this turn writes to.
291 pub(super) fn lines(&mut self) -> &mut HeldLines {
292 self.lines
293 }
294
295 /// The barrier a turn pays before an outside effect
296 /// ([`HeldLines::barrier`]).
297 ///
298 /// # Errors
299 /// Propagates the ledger's refusal.
300 pub(super) fn barrier(&mut self, ledger: &mut dyn Ledger) -> Result<(), AxError> {
301 self.lines.barrier(ledger)
302 }
303
304 /// Appends an event the turn authored, verbatim. The line is held
305 /// until the next [`Journal::barrier`].
306 pub(super) fn append_authored(&mut self, event: Authored, data: Payload) -> Entry {
307 let at = event.at(self.t);
308 self.append(event.kind(), at, None, data)
309 }
310
311 /// Appends a line the run wrote at `addr`, at the turn's stamp. The
312 /// line is held until the next [`Journal::barrier`].
313 pub(super) fn append_run_line(&mut self, line: RunLine, addr: Address, data: Payload) -> Entry {
314 self.append(line.kind(), self.t, Some(addr), data)
315 }
316
317 /// Appends an event the turn carried, with every secret-shaped span
318 /// replaced by a `secret:redacted/<b3-16>` marker first. The line is
319 /// held until the next [`Journal::barrier`].
320 ///
321 /// The window already holds the blocks the next request is built
322 /// from, so replacing here cannot break a thinking block's
323 /// signature or make the conversation stop making sense: history
324 /// and context are two sinks, and only this one is permanent.
325 ///
326 /// # Errors
327 /// Reports `E_INVALID_ARGS` when the scanned map is not a valid
328 /// payload.
329 pub(super) fn append_redacted(
330 &mut self,
331 event: Carried,
332 data: Payload,
333 ) -> Result<Entry, AxError> {
334 let (scanned, hits) = crate::redact::redact(data.into_map());
335 self.redacted = self.redacted.saturating_add(hits);
336 Ok(self.append(event.kind(), event.at(), None, Payload::new(scanned)?))
337 }
338
339 /// The turn's stamp: the time every line of this turn carries except
340 /// the ones it waited for, and the time a wave's tools are admitted at.
341 pub(super) fn stamp(&self) -> TimeMs {
342 self.t
343 }
344
345 /// One reading of both clocks: the moment a line carries, and the
346 /// monotonic microseconds a duration is the difference of.
347 ///
348 /// # Errors
349 /// Propagates the clock's failure, such as a clock past `u64`.
350 pub(super) fn read_moment(&mut self) -> Result<Moment, AxError> {
351 Ok(Moment {
352 at: (self.now)()?,
353 us: self.stopwatch.as_mut().map(|read| read()),
354 })
355 }
356
357 fn append(
358 &mut self,
359 kind: EventKind,
360 at: TimeMs,
361 addr: Option<Address>,
362 data: Payload,
363 ) -> Entry {
364 self.lines.hold(kind, at, addr, data)
365 }
366}
367
368#[cfg(test)]
369#[allow(clippy::unwrap_used, reason = "test code")]
370mod a_ref_before_its_barrier {
371 use super::*;
372
373 /// A ref asked for before the barrier names a history that may not
374 /// exist; the refusal is one sentence.
375 #[test]
376 fn is_refused_in_one_sentence() {
377 let refused = HeldLines::open(RunId::from_bytes([7; 16]), "resident@sim.1".to_owned())
378 .durable(Entry(0))
379 .unwrap_err();
380 assert_eq!(
381 refused.recovery(),
382 "report this against runtime::turn::ledger: a ref was asked for before the barrier that carries its line"
383 );
384 }
385}