diff --git a/crates/stella-cli/src/command_deck.rs b/crates/stella-cli/src/command_deck.rs index 691234133..4e0b7b661 100644 --- a/crates/stella-cli/src/command_deck.rs +++ b/crates/stella-cli/src/command_deck.rs @@ -2621,6 +2621,7 @@ fn service_inspect_action( verified: recon.is_verified(), unresolved: recon.unresolved.len(), digest_mismatches: recon.digest_mismatches.len(), + journal_era: crate::inspect::deck_journal_era(recon.journal_era), }))); }); } diff --git a/crates/stella-cli/src/inspect.rs b/crates/stella-cli/src/inspect.rs index 42b077ff6..b8eee938d 100644 --- a/crates/stella-cli/src/inspect.rs +++ b/crates/stella-cli/src/inspect.rs @@ -79,7 +79,7 @@ use serde::Serialize; // prompt-diff route and this command share one implementation. use stella_diff::{Diff, Op, unified_diff}; use stella_protocol::{CompletionMessage, MessageRole, ToolOutput}; -use stella_store::{Reconstruction, RecordedCall, Store}; +use stella_store::{JournalEra, MismatchSeverity, Reconstruction, RecordedCall, Store}; use crate::query_format::{QueryFormat, Rows, Versioned}; @@ -436,12 +436,6 @@ fn print_reconstruction( // documented coverage gap, a digest mismatch means the bytes recovered for // a block are not the bytes it recorded. Collapsing them into "unverified" // would hide which one happened. - // - // Neither is phrased as tampering. A mismatch is almost always routine — - // a compaction pass rewrote a tool result in place without the journal - // learning about it, so replay recovers the pre-compaction output. Calling - // ordinary housekeeping an integrity breach trains the reader to ignore - // the line, which is the one outcome that would matter if it were real. if !recon.unresolved.is_empty() { println!( "! {} block(s) could not be resolved (synthetic results, discarded \ @@ -450,13 +444,8 @@ fn print_reconstruction( recon.unresolved.join(", ") ); } - if !recon.digest_mismatches.is_empty() { - println!( - "! {} block(s) did NOT re-hash to their recorded digest — shown from the closest \ - preimage (usually a compaction rewrite): {}", - recon.digest_mismatches.len(), - recon.digest_mismatches.join(", ") - ); + if let Some(line) = digest_mismatch_line(recon) { + println!("{line}"); } if recon.is_verified() { println!("verified: every journal-resolved block re-hashed to its recorded digest"); @@ -483,6 +472,42 @@ fn print_reconstruction( ); } +/// The digest-mismatch line, or `None` when nothing mismatched. +/// +/// A pure function because the wording *is* the feature, and the two eras it +/// distinguishes are the point of #1981: on a journal written before +/// compaction recorded its rewrites (#1667) a compacted block mismatches as a +/// matter of course, so calling that an integrity breach trains the reader to +/// skip the line — the one outcome that would matter if it were real. On a +/// journal that records every rewrite there is no routine explanation left, +/// and the alarm is the honest reading. +/// +/// The severity verdict comes from [`Reconstruction::mismatch_severity`] +/// rather than from any reasoning here, so this surface cannot drift from +/// `stella trace`, the deck overlay, or the observatory. +pub(crate) fn digest_mismatch_line(recon: &Reconstruction) -> Option { + let count = recon.digest_mismatches.len(); + let ids = recon.digest_mismatches.join(", "); + match recon.mismatch_severity() { + MismatchSeverity::None => None, + MismatchSeverity::Compaction => Some(format!( + "! {count} block(s) did NOT re-hash to their recorded digest — shown from the \ + closest preimage. This journal predates compaction recording its rewrites, so a \ + compacted block reads this way as a matter of course: {ids}" + )), + // `!!` rather than `!`: the marker is the only part of the banner a + // reader scanning output will register, and the two eras must not + // share one. The line is otherwise uncoloured like every other banner + // line here — a severity that survives a pipe is worth more than one + // that needs a terminal. + MismatchSeverity::Integrity => Some(format!( + "!! {count} block(s) did NOT re-hash to their recorded digest. This journal records \ + every compaction rewrite, so nothing routine accounts for these bytes — treat the \ + reconstruction as untrustworthy: {ids}" + )), + } +} + /// The earlier state a diff is taken against, already resolved to bytes plus /// the label the `---` header prints. struct Baseline { @@ -900,9 +925,49 @@ struct ReconstructionJson { verified: bool, unresolved: Vec, digest_mismatches: Vec, + /// Which compaction-journaling era wrote this execution's journal — + /// `compaction_journaled` or `compaction_unjournaled`. A script cannot + /// read `digest_mismatches` honestly without it: on an unjournaled journal + /// a compacted block mismatches as a matter of course. + journal_era: &'static str, + /// What those mismatches mean here — `none`, `compaction`, or `integrity`. + /// Derived from the two fields above so a caller never has to combine them + /// itself, and the same three words every other surface styles from. + digest_mismatch_severity: &'static str, messages: Vec, } +/// The stable JSON spelling of an era. Named constants rather than `Debug`, +/// because these are a wire contract for `--format json` consumers. +fn era_tag(era: JournalEra) -> &'static str { + match era { + JournalEra::CompactionUnjournaled => "compaction_unjournaled", + JournalEra::CompactionJournaled => "compaction_journaled", + } +} + +/// The deck's mirror of a store era. `stella-tui` links no store, so the +/// driver maps the two — the same bargain every other type on the inspect +/// envelope takes, and it lives here beside the other inspect mappings rather +/// than in `command_deck.rs`. +pub(crate) fn deck_journal_era(era: JournalEra) -> stella_tui::JournalEra { + match era { + JournalEra::CompactionUnjournaled => stella_tui::JournalEra::CompactionUnjournaled, + JournalEra::CompactionJournaled => stella_tui::JournalEra::CompactionJournaled, + } +} + +/// The stable JSON spelling of a mismatch verdict. Shared with the observatory +/// payload by convention, not by linkage — that crate links no workspace crate +/// (see its README) — so the three words are pinned by a test on both sides. +pub(crate) fn severity_tag(severity: MismatchSeverity) -> &'static str { + match severity { + MismatchSeverity::None => "none", + MismatchSeverity::Compaction => "compaction", + MismatchSeverity::Integrity => "integrity", + } +} + fn execution_json(row: &stella_store::InspectableExecution) -> ExecutionJson { ExecutionJson { execution_id: row.execution_id, @@ -932,6 +997,8 @@ fn reconstruction_json(recon: &Reconstruction) -> ReconstructionJson { verified: recon.is_verified(), unresolved: recon.unresolved.clone(), digest_mismatches: recon.digest_mismatches.clone(), + journal_era: era_tag(recon.journal_era), + digest_mismatch_severity: severity_tag(recon.mismatch_severity()), messages: recon .messages .iter() @@ -972,6 +1039,81 @@ fn floor_char_boundary(s: &str, limit: usize) -> usize { mod tests { use super::*; + /// A reconstruction with one mismatched block, written in the given era. + fn mismatched(journal_era: JournalEra) -> Reconstruction { + Reconstruction { + messages: Vec::new(), + unresolved: Vec::new(), + digest_mismatches: vec!["blk_res".into()], + journal_era, + } + } + + #[test] + fn the_mismatch_banner_reads_differently_in_each_journal_era() { + // The CLI half of #1981. Before it, both of these printed the same + // benign line — correct for the first, a missed integrity signal for + // the second. + let legacy = digest_mismatch_line(&mismatched(JournalEra::CompactionUnjournaled)) + .expect("a mismatch is always reported"); + let current = digest_mismatch_line(&mismatched(JournalEra::CompactionJournaled)) + .expect("a mismatch is always reported"); + assert_ne!(legacy, current); + assert!(legacy.starts_with("! "), "still a warning: {legacy}"); + assert!(current.starts_with("!! "), "an alarm: {current}"); + assert!( + legacy.contains("as a matter of course"), + "the legacy line names compaction as the routine cause: {legacy}" + ); + assert!( + current.contains("untrustworthy"), + "the current-era line says what to do about it: {current}" + ); + // Both name the block, because a verdict with no subject is not one. + assert!(legacy.contains("blk_res") && current.contains("blk_res")); + } + + #[test] + fn a_clean_reconstruction_prints_no_mismatch_line_in_either_era() { + for era in [ + JournalEra::CompactionUnjournaled, + JournalEra::CompactionJournaled, + ] { + let clean = Reconstruction { + messages: Vec::new(), + unresolved: Vec::new(), + digest_mismatches: Vec::new(), + journal_era: era, + }; + assert!(digest_mismatch_line(&clean).is_none()); + assert_eq!(severity_tag(clean.mismatch_severity()), "none"); + } + } + + #[test] + fn the_json_verdict_words_match_the_observatorys() { + // The dashboard emits these same three words for the same payload and + // links no store crate, so the two spellings are pinned by a test on + // each side (`crates/stella-observatory/tests/journal_era.rs`) rather + // than by a shared type. If one moves, one of the two fails. + assert_eq!( + severity_tag(mismatched(JournalEra::CompactionUnjournaled).mismatch_severity()), + "compaction" + ); + assert_eq!( + severity_tag(mismatched(JournalEra::CompactionJournaled).mismatch_severity()), + "integrity" + ); + assert_eq!( + era_tag(JournalEra::CompactionUnjournaled), + "compaction_unjournaled" + ); + assert_eq!( + era_tag(JournalEra::CompactionJournaled), + "compaction_journaled" + ); + } + #[test] fn truncate_counts_characters_not_bytes() { assert_eq!(truncate("abc", 8), "abc"); diff --git a/crates/stella-cli/src/trace.rs b/crates/stella-cli/src/trace.rs index 1361ba588..993509740 100644 --- a/crates/stella-cli/src/trace.rs +++ b/crates/stella-cli/src/trace.rs @@ -236,10 +236,17 @@ pub fn assemble( ) { Ok(reconstruction) => { let verified = reconstruction.is_verified(); + // The severity rides along with the ids, because a trace is + // read long after the run and by someone who cannot ask which + // build wrote it. `compaction` says the mismatch is the + // pre-#1667 journal's routine one; `integrity` says nothing + // routine explains it. Same verdict `stella inspect` and the + // deck render — one source, four surfaces (#1981). let error = (!verified).then(|| { format!( - "unresolved: [{}]; digest mismatches: [{}]", + "unresolved: [{}]; digest mismatches ({}): [{}]", reconstruction.unresolved.join(", "), + crate::inspect::severity_tag(reconstruction.mismatch_severity()), reconstruction.digest_mismatches.join(", ") ) }); diff --git a/crates/stella-cli/tests/inspect_cli.rs b/crates/stella-cli/tests/inspect_cli.rs index 9d9ec7fab..eae42cb4a 100644 --- a/crates/stella-cli/tests/inspect_cli.rs +++ b/crates/stella-cli/tests/inspect_cli.rs @@ -286,6 +286,12 @@ fn inspect_json_is_machine_readable_and_reports_verification() { let out = inspect(&dir, &[&id.to_string(), "--step", "0", "--format", "json"]); let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid json"); assert_eq!(parsed["verified"], true, "clean path verifies: {out}"); + // The era a script needs to read `digest_mismatches` honestly, straight + // out of the shipped binary: this execution was begun by this build, so it + // journals its compaction rewrites and a mismatch here would mean + // something (#1981). Nothing mismatched, so the severity is `none`. + assert_eq!(parsed["journal_era"], "compaction_journaled", "{out}"); + assert_eq!(parsed["digest_mismatch_severity"], "none", "{out}"); let messages = parsed["messages"].as_array().expect("messages array"); assert_eq!(messages.len(), 2, "system + user: {out}"); assert_eq!(messages[0]["role"], "system"); diff --git a/crates/stella-observatory/README.md b/crates/stella-observatory/README.md index 4142e5f7d..9f7f5be5b 100644 --- a/crates/stella-observatory/README.md +++ b/crates/stella-observatory/README.md @@ -53,8 +53,10 @@ reconstruction `stella_store::Store::reconstruct_call` performs (#1475). The last is the largest of the four and the only one with a *byte-level* coupling — it rebuilds a `tool_call` block's preimage in `stella_protocol::ToolCall`'s field order — so `tests/schema_conformance.rs` seeds its digests from that -crate's own serializer: a reordered field fails the suite instead of printing -"the journal is torn" on a user's dashboard. +crate's own serializer: a reordered field fails the suite instead of raising an +integrity alarm on a user's dashboard. `tests/journal_era.rs` covers the other +half of that alarm — which of the two things a digest mismatch means depends on +the journal's era, read from `executions.journal_era` (#1981). Only [`stella-cli`](../stella-cli) depends on it: `run_observe` ([`../stella-cli/src/storage_cmd.rs:47`](../stella-cli/src/storage_cmd.rs)) diff --git a/crates/stella-observatory/src/assets/index.html b/crates/stella-observatory/src/assets/index.html index 33426258a..6c1153030 100644 --- a/crates/stella-observatory/src/assets/index.html +++ b/crates/stella-observatory/src/assets/index.html @@ -1493,9 +1493,19 @@

${esc(d.prompt)}

`; } /* The two failure modes stay separate, as they do on the CLI: an unresolved - block is a documented coverage gap, a digest mismatch is a torn-journal or - tampering signal. Collapsing them into one "unverified" word would hide - which one happened. */ + block is a documented coverage gap, a digest mismatch is a statement about + bytes. Collapsing them into one "unverified" word would hide which one + happened. + + A mismatch means one of two things, and the payload's + `digest_mismatch_severity` says which (#1981). On a journal written before + compaction recorded its rewrites (#1667) a compacted block mismatches as a + matter of course — this panel used to call that a torn or altered journal, + which is a tampering accusation levelled at routine housekeeping, and an + alarm that fires on housekeeping is one nobody reads. On a journal that + records every rewrite there is no such explanation left, and the alarm is + the honest reading. Never decided here: the server sends the verdict every + surface styles from. */ function sentContextVerdictHtml(c) { if (c.verified) { return `
✓ verified · every @@ -1503,14 +1513,25 @@

${esc(d.prompt)}

} const bad = (c.digest_mismatches ?? []).length; const gaps = (c.unresolved ?? []).length; - return `${bad ? `
✕ ${fmtInt(bad)} block(s) - did NOT re-hash to their recorded digest — the journal is torn or was altered
` : ""} + const integrity = c.digest_mismatch_severity === "integrity"; + const mismatch = integrity + ? `
✕ ${fmtInt(bad)} block(s) did NOT re-hash to + their recorded digest — this journal records every compaction rewrite, so these bytes + are unaccounted for
` + : `
! ${fmtInt(bad)} block(s) did not re-hash — + shown from the closest preimage. This journal predates compaction recording its + rewrites, so a compacted block reads this way as a matter of course
`; + return `${bad ? mismatch : ""} ${gaps ? `
◌ ${fmtInt(gaps)} block(s) could not be resolved (synthetic results, discarded speculation, or attachments)
` : ""}`; } -function sentContextMessageHtml(m) { +/* `integrity` comes from the verdict above rather than from the blocks alone: + a mismatched block on a pre-#1667 journal is compaction's doing, and putting + the danger rail on its message would be the same false alarm the verdict + line no longer raises. */ +function sentContextMessageHtml(m, integrity) { const blocks = m.blocks ?? []; - const torn = blocks.some(b => b.digest_verified === false); + const torn = integrity && blocks.some(b => b.digest_verified === false); return `
[${fmtInt(m.index)}] ${esc(m.role)} · ${fmtInt(blocks.length)} block${ blocks.length === 1 ? "" : "s"}${ @@ -1536,7 +1557,9 @@

${esc(d.prompt)}

return; } const messages = c.messages ?? []; - body.innerHTML = `${sentContextVerdictHtml(c)}${messages.map(sentContextMessageHtml).join("")}${ + const integrity = c.digest_mismatch_severity === "integrity"; + body.innerHTML = `${sentContextVerdictHtml(c)}${ + messages.map(m => sentContextMessageHtml(m, integrity)).join("")}${ messages.some(m => m.truncated) ? `` : ""}`; const btn = $("ctx-full"); diff --git a/crates/stella-observatory/src/context_diff.rs b/crates/stella-observatory/src/context_diff.rs index ac0bccd84..bdca81664 100644 --- a/crates/stella-observatory/src/context_diff.rs +++ b/crates/stella-observatory/src/context_diff.rs @@ -31,7 +31,9 @@ use rusqlite::Connection; use serde_json::{Value, json}; use crate::db::{DbError, is_missing_schema}; -use crate::sent_context::{Preimages, journal_payloads, manifest_entries, reconstruct}; +use crate::sent_context::{ + Preimages, journal_era, journal_payloads, manifest_entries, reconstruct, +}; /// Context lines framing each hunk — the same default `stella inspect --diff` /// and `git diff` use. @@ -99,7 +101,13 @@ pub(crate) fn payload( role, }; let preimages = Preimages::index(&journal_payloads(conn, id)?); - let target_doc = render_document(&reconstruct(&entries, &preimages, true), only); + // The diff renders only the document, never the verdict — but the era is + // read for real rather than assumed, so no call site of `reconstruct` + // passes a value it has not actually looked up. + let target_doc = render_document( + &reconstruct(&entries, &preimages, journal_era(conn, id), true), + only, + ); let baseline = resolve_baseline(conn, &target, base, only)?; let diff = stella_diff::unified_diff(&baseline.document, &target_doc, DEFAULT_CONTEXT); @@ -192,7 +200,15 @@ fn resolve_baseline( Ok(Baseline { label: previous.label(target.execution_id), kind: if base == "first" { "first" } else { "prev" }, - document: render_document(&reconstruct(&entries, &preimages, true), only), + document: render_document( + &reconstruct( + &entries, + &preimages, + journal_era(conn, previous.execution_id), + true, + ), + only, + ), }) } diff --git a/crates/stella-observatory/src/sent_context.rs b/crates/stella-observatory/src/sent_context.rs index b0509ef54..7a901c68b 100644 --- a/crates/stella-observatory/src/sent_context.rs +++ b/crates/stella-observatory/src/sent_context.rs @@ -160,7 +160,8 @@ fn call_context( return Ok(out); } let preimages = Preimages::index(&journal_payloads(conn, id)?); - crate::db::merge(&mut out, reconstruct(&entries, &preimages, full)); + let era = journal_era(conn, id); + crate::db::merge(&mut out, reconstruct(&entries, &preimages, era, full)); Ok(out) } @@ -382,6 +383,57 @@ fn sha256_hex(s: &str) -> String { .collect() } +/// Which compaction-journaling era wrote an execution's events — an +/// acknowledged mirror of `stella_store::JournalEra`, on the same bargain as +/// the rest of this module (this crate links no store; see the module docs). +/// +/// The point of the stamp is that it is *recorded*, not inferred: a compacted +/// block on a pre-#1667 journal mismatches as a matter of course, and styling +/// that as tampering is a false alarm on ordinary housekeeping. The dashboard +/// used to do exactly that for every era at once (#1981). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum JournalEra { + /// Compaction rewrote tool results in place and journaled nothing about + /// it. The default, and what a store too old to carry the column reads as: + /// nothing known must never be rendered as an integrity failure. + #[default] + CompactionUnjournaled, + /// Every compaction rewrite is journaled (#1667), so a mismatch has no + /// routine explanation left. + CompactionJournaled, +} + +impl JournalEra { + /// The wire spelling, shared by convention with `stella inspect --format + /// json`'s `journal_era` — the two are pinned by a test on each side + /// rather than by linkage. + fn tag(self) -> &'static str { + match self { + Self::CompactionUnjournaled => "compaction_unjournaled", + Self::CompactionJournaled => "compaction_journaled", + } + } +} + +/// The era stamped on an execution row (`executions.journal_era`, schema v22). +/// +/// Every failure reads as [`JournalEra::CompactionUnjournaled`], and they are +/// all ordinary: a store written before v22 has no such column, and an +/// execution the dashboard was pointed at may not exist. Neither is a state in +/// which this crate may claim an integrity failure on a user's telemetry, so +/// the unknown answer is the quiet one. +pub(crate) fn journal_era(conn: &Connection, id: i64) -> JournalEra { + let code: Result = conn.query_row( + "SELECT journal_era FROM executions WHERE id = ?1", + [id], + |r| r.get(0), + ); + match code { + Ok(1) => JournalEra::CompactionJournaled, + _ => JournalEra::CompactionUnjournaled, + } +} + /// One message under construction — the regrouping target for every block /// sharing a `message_index`. struct Message { @@ -396,7 +448,12 @@ struct Message { /// `full` lifts the per-body clip, exactly as it does on the transcript route: /// both go through `set_journal_body`, so the two views can never disagree /// about what "clipped" means. -pub(crate) fn reconstruct(entries: &[ManifestEntry], preimages: &Preimages, full: bool) -> Value { +pub(crate) fn reconstruct( + entries: &[ManifestEntry], + preimages: &Preimages, + era: JournalEra, + full: bool, +) -> Value { let mut messages: Vec = Vec::new(); let mut unresolved: Vec = Vec::new(); let mut mismatches: Vec = Vec::new(); @@ -461,10 +518,24 @@ pub(crate) fn reconstruct(entries: &[ManifestEntry], preimages: &Preimages, full }) .collect(); + // `verified` stays era-blind: a mismatch happened or it did not. The era + // decides only how loudly it is reported, which is what + // `digest_mismatch_severity` carries — the same three words `stella + // inspect --format json` emits, so the dashboard and the CLI cannot come + // to different conclusions about one execution (#1981). + let severity = if mismatches.is_empty() { + "none" + } else if era == JournalEra::CompactionJournaled { + "integrity" + } else { + "compaction" + }; json!({ "verified": unresolved.is_empty() && mismatches.is_empty(), "unresolved": unresolved, "digest_mismatches": mismatches, + "journal_era": era.tag(), + "digest_mismatch_severity": severity, "messages": rendered, }) } @@ -664,7 +735,7 @@ mod tests { }, ]; - let out = reconstruct(&entries, &preimages, false); + let out = reconstruct(&entries, &preimages, JournalEra::CompactionJournaled, false); assert_eq!(out["verified"], true, "{out}"); assert_eq!(out["messages"][0]["role"], "system"); assert_eq!(out["messages"][0]["body"], "you are careful"); @@ -690,7 +761,7 @@ mod tests { birth_call_id: Some("missing".to_owned()), ..entry("blk_orphan", "tool_result", 0) }]; - let out = reconstruct(&entries, &preimages, false); + let out = reconstruct(&entries, &preimages, JournalEra::CompactionJournaled, false); assert_eq!(out["verified"], false); assert_eq!(out["unresolved"], json!(["blk_orphan"])); assert_eq!(out["messages"], json!([])); @@ -708,7 +779,7 @@ mod tests { ..entry("blk_text", "assistant_text", 0) }]; // Nothing resolves at all when the digest is the lookup key… - let out = reconstruct(&entries, &preimages, false); + let out = reconstruct(&entries, &preimages, JournalEra::CompactionJournaled, false); assert_eq!(out["unresolved"], json!(["blk_text"])); // …so stage the mismatch through a kind that resolves by call id. @@ -722,7 +793,7 @@ mod tests { birth_call_id: Some("c1".to_owned()), ..entry("blk_res", "tool_result", 0) }]; - let out = reconstruct(&entries, &preimages, false); + let out = reconstruct(&entries, &preimages, JournalEra::CompactionJournaled, false); assert_eq!(out["verified"], false); assert_eq!(out["digest_mismatches"], json!(["blk_res"])); assert_eq!(out["messages"][0]["blocks"][0]["digest_verified"], false); @@ -756,7 +827,7 @@ mod tests { birth_call_id: Some("e1".to_owned()), ..entry("blk_err", "tool_result", 0) }]; - let out = reconstruct(&entries, &preimages, false); + let out = reconstruct(&entries, &preimages, JournalEra::CompactionJournaled, false); assert_eq!(out["verified"], true, "{out}"); assert_eq!( out["messages"][0]["body"], diff --git a/crates/stella-observatory/src/tests.rs b/crates/stella-observatory/src/tests.rs index e50eed674..820e9de9f 100644 --- a/crates/stella-observatory/src/tests.rs +++ b/crates/stella-observatory/src/tests.rs @@ -520,6 +520,12 @@ fn execution_context_rebuilds_the_message_array_a_call_was_sent() { assert_eq!(context["verified"], true, "{v}"); assert_eq!(context["unresolved"], serde_json::json!([])); assert_eq!(context["digest_mismatches"], serde_json::json!([])); + // Nothing mismatched, so there is no severity to report — and the era + // reads as the pre-rewrite one because this fixture's `executions` table + // predates the column, which is exactly how a store written by an older + // build behaves (#1981). + assert_eq!(context["digest_mismatch_severity"], "none"); + assert_eq!(context["journal_era"], "compaction_unjournaled"); // A gap block's preimage is stored locally, so its check is tautological // and is deliberately not reported as evidence. assert!(messages[0]["blocks"][0]["digest_verified"].is_null()); diff --git a/crates/stella-observatory/tests/journal_era.rs b/crates/stella-observatory/tests/journal_era.rs new file mode 100644 index 000000000..761249dcc --- /dev/null +++ b/crates/stella-observatory/tests/journal_era.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Oxagen, Inc. Commercial licensing: licensing@oxagen.sh + +//! **The two-era witness for #1981**, driven end to end through the served +//! `/api/execution-context` payload. +//! +//! A digest mismatch means two different things depending on who wrote the +//! journal. On a journal written before compaction recorded its replacement +//! bytes (#1667/PR #1979), a compacted tool result can only resolve through +//! the `call_id` fallback — so it mismatches, benignly, forever; #1668 +//! downgraded every mismatch surface to a warning for exactly that reason. On +//! a journal that records every rewrite, the block resolves by digest to what +//! the step actually sent, so a mismatch has no routine explanation left and +//! is a real integrity signal again. +//! +//! Both readings are correct, for different journals. This suite pins that the +//! dashboard tells them apart: two executions identical in every byte except +//! `executions.journal_era` must not produce the same verdict. Before #1981 +//! there was no era signal at all and they did. +//! +//! It lives in `tests/` rather than beside the unit tests because +//! `src/tests.rs` is close to the 1500-line ratchet, and it builds its fixture +//! with hand-written SQL for the same reason the production reader does — this +//! crate does not link `stella-store` outside the schema-drift gate. + +use rusqlite::Connection; +use stella_observatory::respond; +use tempfile::TempDir; + +/// A digest no content can re-hash to, so the block mismatches for a reason +/// the fixture states outright rather than by arranging a near-miss. +const UNREACHABLE_DIGEST: &str = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + +/// The execution stamped as having journaled its compaction rewrites. +const CURRENT_ERA: i64 = 1; +/// The execution stamped as predating that — what every row written before +/// schema v22 backfills to. +const LEGACY_ERA: i64 = 0; + +/// Two executions differing in exactly one column: the era stamp. Each has one +/// recorded worker call whose single `tool_result` block resolves from the +/// journal and fails its digest check. +fn workspace_with_both_eras() -> TempDir { + let dir = TempDir::new().unwrap(); + let private = dir.path().join(".stella/private"); + std::fs::create_dir_all(&private).unwrap(); + let conn = Connection::open(private.join("store.db")).unwrap(); + conn.execute_batch( + "CREATE TABLE executions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, prompt TEXT NOT NULL, + provider TEXT NOT NULL, model TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TEXT, outcome TEXT, + cost_usd REAL NOT NULL DEFAULT 0, + journal_era INTEGER NOT NULL DEFAULT 0); + CREATE TABLE events ( + execution_id INTEGER NOT NULL, seq INTEGER NOT NULL, + ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + event_type TEXT NOT NULL, payload TEXT NOT NULL, + UNIQUE (execution_id, seq)); + CREATE TABLE step_receipt ( + execution_id INTEGER NOT NULL, turn_instance INTEGER NOT NULL, + step INTEGER NOT NULL, call_seq INTEGER NOT NULL DEFAULT 0, + provider TEXT NOT NULL, model TEXT NOT NULL, call_role TEXT NOT NULL, + effective_budget_tokens INTEGER NOT NULL, + calibration_factor REAL NOT NULL, + estimated_input_tokens INTEGER NOT NULL, + compiled_frame_id TEXT, frame_hash TEXT, + PRIMARY KEY (execution_id, turn_instance, step, call_seq)); + CREATE TABLE step_manifest ( + execution_id INTEGER NOT NULL, turn_instance INTEGER NOT NULL, + step INTEGER NOT NULL, call_seq INTEGER NOT NULL DEFAULT 0, + ordinal INTEGER NOT NULL, block_id TEXT NOT NULL, + cache_zone TEXT NOT NULL, resident_since_step INTEGER NOT NULL, + message_index INTEGER NOT NULL DEFAULT 0, call_id TEXT, + PRIMARY KEY (execution_id, turn_instance, step, call_seq, ordinal)); + CREATE TABLE context_blocks ( + execution_id INTEGER NOT NULL, block_id TEXT NOT NULL, + kind TEXT NOT NULL, origin_turn INTEGER NOT NULL, + origin_step INTEGER NOT NULL, call_id TEXT, memory_id TEXT, + token_cost INTEGER, content_digest TEXT NOT NULL, + citation_label TEXT, content TEXT, + first_seen_ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (execution_id, block_id));", + ) + .unwrap(); + + for (id, era) in [(1_i64, CURRENT_ERA), (2, LEGACY_ERA)] { + conn.execute( + "INSERT INTO executions (id, kind, prompt, provider, model, journal_era) + VALUES (?1, 'run', 'fix it', 'zai', 'glm-5.2', ?2)", + rusqlite::params![id, era], + ) + .unwrap(); + conn.execute( + "INSERT INTO events (execution_id, seq, event_type, payload) VALUES (?1, 0, \ + 'tool_result', '{\"type\":\"tool_result\",\"call_id\":\"c1\",\"output\":{\"ok\":\ + {\"content\":\"fn a() {}\"}},\"duration_ms\":1,\"speculated\":false}')", + rusqlite::params![id], + ) + .unwrap(); + conn.execute( + "INSERT INTO step_receipt + (execution_id, turn_instance, step, call_seq, provider, model, call_role, + effective_budget_tokens, calibration_factor, estimated_input_tokens) + VALUES (?1, 0, 1, 0, 'zai', 'glm-5.2', 'worker', 1000, 1.0, 10)", + rusqlite::params![id], + ) + .unwrap(); + conn.execute( + "INSERT INTO context_blocks + (execution_id, block_id, kind, origin_turn, origin_step, call_id, + token_cost, content_digest, content) + VALUES (?1, 'blk_res', 'tool_result', 0, 1, 'c1', 10, ?2, NULL)", + rusqlite::params![id, UNREACHABLE_DIGEST], + ) + .unwrap(); + conn.execute( + "INSERT INTO step_manifest + (execution_id, turn_instance, step, call_seq, ordinal, block_id, + cache_zone, resident_since_step, message_index, call_id) + VALUES (?1, 0, 1, 0, 0, 'blk_res', 'volatile', 1, 0, 'c1')", + rusqlite::params![id], + ) + .unwrap(); + } + dir +} + +fn context_of(root: &std::path::Path, id: i64) -> serde_json::Value { + let response = respond( + root, + &format!("/api/execution-context?id={id}&turn=0&step=1&call_seq=0"), + ); + let payload: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + payload["context"].clone() +} + +#[test] +fn the_same_mismatch_reads_as_housekeeping_on_a_legacy_journal_and_an_alarm_on_a_current_one() { + let ws = workspace_with_both_eras(); + let current = context_of(ws.path(), 1); + let legacy = context_of(ws.path(), 2); + + // The two reconstructions are the same failure: same block, same + // unresolvable digest, same rebuilt bytes. + assert_eq!(current["found"], true, "{current}"); + assert_eq!(legacy["found"], true, "{legacy}"); + assert_eq!(current["digest_mismatches"], serde_json::json!(["blk_res"])); + assert_eq!(legacy["digest_mismatches"], current["digest_mismatches"]); + assert_eq!(legacy["messages"], current["messages"]); + assert_eq!(current["verified"], false); + assert_eq!(legacy["verified"], false); + + // And they must still not be reported the same way. + assert_eq!( + legacy["digest_mismatch_severity"], "compaction", + "a pre-#1667 journal mismatches on ordinary compaction — calling that \ + an integrity failure is the false alarm #1668 removed: {legacy}" + ); + assert_eq!( + current["digest_mismatch_severity"], "integrity", + "this journal records every compaction rewrite, so nothing routine \ + accounts for these bytes: {current}" + ); + assert_ne!( + legacy["digest_mismatch_severity"], + current["digest_mismatch_severity"] + ); + + // The era itself is on the payload too, so a script does not have to infer + // it from the severity of a failure that may not have happened. + assert_eq!(legacy["journal_era"], "compaction_unjournaled"); + assert_eq!(current["journal_era"], "compaction_journaled"); +} + +#[test] +fn a_store_too_old_to_carry_the_era_column_stays_quiet() { + // The dashboard reads other people's databases, including ones written + // before schema v22 added the column. That read must degrade to the benign + // era rather than to a 500 or to an accusation. + let dir = TempDir::new().unwrap(); + let private = dir.path().join(".stella/private"); + std::fs::create_dir_all(&private).unwrap(); + { + let source = workspace_with_both_eras(); + std::fs::copy( + source.path().join(".stella/private/store.db"), + private.join("store.db"), + ) + .unwrap(); + let conn = Connection::open(private.join("store.db")).unwrap(); + conn.execute_batch("ALTER TABLE executions DROP COLUMN journal_era;") + .unwrap(); + } + + let context = context_of(dir.path(), 1); + assert_eq!(context["found"], true, "the route still answers: {context}"); + assert_eq!(context["journal_era"], "compaction_unjournaled"); + assert_eq!(context["digest_mismatch_severity"], "compaction"); +} diff --git a/crates/stella-observatory/tests/schema_conformance.rs b/crates/stella-observatory/tests/schema_conformance.rs index 4d1e4b25a..9658cc78b 100644 --- a/crates/stella-observatory/tests/schema_conformance.rs +++ b/crates/stella-observatory/tests/schema_conformance.rs @@ -836,6 +836,18 @@ fn sent_context_reconstructs_a_real_stores_receipt_and_verifies_it() { stella_protocol's own serializer produced — a wire shape moved under \ the observatory's hand-built preimage: {v}" ); + // The era stamp is read from the real column the real writer set (#1981). + // This is the drift half of that signal: rename `executions.journal_era` in + // stella-store and the dashboard would quietly fall back to the legacy era + // for every execution, under-reporting a genuine integrity failure with no + // test saying a word. `tests/journal_era.rs` pins what the two eras mean; + // this pins that the column is still there to read. + assert_eq!( + context["journal_era"], "compaction_journaled", + "an execution this build's Store began must be stamped as journaling \ + its compaction rewrites: {v}" + ); + assert_eq!(context["digest_mismatch_severity"], "none", "{v}"); } /// **The other half of the drift problem, and the one that actually bit.** diff --git a/crates/stella-store/README.md b/crates/stella-store/README.md index c4f35571c..0447e26c8 100644 --- a/crates/stella-store/README.md +++ b/crates/stella-store/README.md @@ -115,7 +115,7 @@ escape hatch for an irreducible line (a module declaration in an oversized [`ddl.rs`](src/ddl.rs) says what the shape *is*; [`migrations.rs`](src/migrations.rs) says how an existing file *gets there*. A fresh database gets `create_latest_schema` -in one shot and is stamped at `SCHEMA_VERSION` (`= MIGRATIONS.len()`, 15 today); +in one shot and is stamped at `SCHEMA_VERSION` (`= MIGRATIONS.len()`, 22 today); an existing one runs each pending step. `PRAGMA user_version` 0 is ambiguous — it is both "fresh empty file" and "legacy pre-versioning file" — so `Store::migrate` disambiguates by probing `TABLES` via `any_store_table_exists`. A file stamped diff --git a/crates/stella-store/src/ddl.rs b/crates/stella-store/src/ddl.rs index 1c9938d94..3b0b2fd43 100644 --- a/crates/stella-store/src/ddl.rs +++ b/crates/stella-store/src/ddl.rs @@ -64,6 +64,16 @@ pub(crate) const TABLES: [&str; 21] = [ /// holds only the open rows — usually zero, at most a handful — so the /// question costs an empty index probe instead of a scan over every /// execution the workspace has ever run. +/// +/// `journal_era` (v22) records which compaction-journaling era wrote this +/// execution's events — see [`JournalEra`](crate::JournalEra). It is stamped +/// by the writer at [`Store::begin_execution`](crate::Store::begin_execution) +/// rather than inferred at read time, because the reader cannot tell "this +/// build journaled no rewrites" from "this build could not" by looking at the +/// events. The `DEFAULT 0` is what every row written before v22 backfills to, +/// and it is deliberately the benign reading: a code this build does not know +/// is treated as the oldest era, so an unfamiliar stamp can only ever +/// under-alarm, never raise a false one. pub(crate) const EXECUTIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS executions ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, @@ -77,7 +87,8 @@ pub(crate) const EXECUTIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS executions ( session_id TEXT, usage_complete INTEGER NOT NULL DEFAULT 0 CHECK(usage_complete IN (0, 1)), usage_status TEXT NOT NULL DEFAULT 'pending' - CHECK(usage_status IN ('pending', 'complete', 'incomplete')) + CHECK(usage_status IN ('pending', 'complete', 'incomplete')), + journal_era INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS executions_by_session ON executions(session_id, id); diff --git a/crates/stella-store/src/lib.rs b/crates/stella-store/src/lib.rs index 59c42e219..82be28bf5 100644 --- a/crates/stella-store/src/lib.rs +++ b/crates/stella-store/src/lib.rs @@ -207,7 +207,7 @@ pub use receipts::{ ContextBlockRow, ExecutionSummary, InspectableExecution, ManifestBlockRow, RecordedCall, StepManifestRow, }; -pub use reconstruct::Reconstruction; +pub use reconstruct::{JournalEra, MismatchSeverity, Reconstruction}; pub use sessions::{SessionRecord, SessionRegistry, SessionStatus, SupervisorInfo, supervised}; /// Re-exported because it *is* this crate's task-board API: every signature /// in [`task_board`] speaks in these, and a caller should not have to name @@ -797,7 +797,7 @@ impl Store { .unwrap_or_else(|poisoned| poisoned.into_inner()) } - /// Start an execution record; returns its id. + /// Start an execution record, stamped [`JournalEra::CURRENT`]; returns its id. pub fn begin_execution( &self, kind: &str, @@ -807,9 +807,9 @@ impl Store { ) -> Result { let conn = self.lock(); conn.execute( - "INSERT INTO executions (kind, prompt, provider, model, usage_complete, usage_status) \ - VALUES (?, ?, ?, ?, 0, 'pending')", - params![kind, prompt, provider, model], + "INSERT INTO executions (kind, prompt, provider, model, usage_complete, usage_status, journal_era) \ + VALUES (?, ?, ?, ?, 0, 'pending', ?)", + params![kind, prompt, provider, model, JournalEra::CURRENT.code()], )?; Ok(conn.last_insert_rowid()) } diff --git a/crates/stella-store/src/migrations.rs b/crates/stella-store/src/migrations.rs index 29c2fdec6..b488b3474 100644 --- a/crates/stella-store/src/migrations.rs +++ b/crates/stella-store/src/migrations.rs @@ -33,7 +33,7 @@ pub(crate) type Migration = fn(&rusqlite::Transaction<'_>) -> Result<()>; /// a file at `user_version` i to i + 1. Fresh files never run these — they /// get [`create_latest_schema`] and are stamped at [`SCHEMA_VERSION`] /// directly. -pub(crate) const MIGRATIONS: [Migration; 21] = [ +pub(crate) const MIGRATIONS: [Migration; 22] = [ // v0 → v1: dedupe events/telemetry, then retrofit the UNIQUE keys // their write paths have always assumed. migrate_v0_to_v1, @@ -131,6 +131,11 @@ pub(crate) const MIGRATIONS: [Migration; 21] = [ // correct starting state: the marks those diffs are computed from were // only ever written going forward too. migrate_v20_to_v21, + // v21 → v22: `executions` grows `journal_era` — which compaction-journaling + // era wrote this row's events (#1981). Additive, column-guarded ADD COLUMN; + // every existing row backfills to era 0, which is exactly what it is: a + // journal written before compaction recorded its replacement bytes. + migrate_v21_to_v22, // ── APPEND POINT — RESERVED SLOTS ─────────────────────────────────── // This is an INDEX-ORDERED array and `SCHEMA_VERSION` is its length, so // a slot is claimed by position, not by name. Two branches that each @@ -157,7 +162,9 @@ pub(crate) const MIGRATIONS: [Migration; 21] = [ // // v20 → v21: CLAIMED above by the per-turn workspace diffs (#1870). // - // Nothing is reserved now: take v21 → v22 and add your own line here. + // v21 → v22: CLAIMED above by the journal-era stamp (#1981). + // + // Nothing is reserved now: take v22 → v23 and add your own line here. // If a reserved phase ships without needing its slot, delete its line // rather than leaving a hole — index order is the contract. ]; @@ -444,6 +451,33 @@ fn migrate_v20_to_v21(tx: &rusqlite::Transaction<'_>) -> Result<()> { Ok(()) } +/// v21 → v22: `executions` grows `journal_era` (#1981) — the writer's own +/// statement of which compaction-journaling era produced this row's events. +/// +/// The backfill is the column default and needs no `UPDATE`: a row already at +/// rest was written by a build that had no era to state, and for all but the +/// few days between PR #1979 landing and this stamp that build genuinely could +/// not journal a rewrite. Rows from inside that window are the one place era 0 +/// is conservative rather than exact — and conservative in the only safe +/// direction, since it can under-report an integrity signal but never invent +/// one. Backfilling them the other way would need the very inference this +/// column exists to avoid: a reader looking at an old execution's events +/// cannot tell "this build journaled no rewrites" from "this run rewrote +/// nothing", and reading it wrong raises a false alarm on routine +/// housekeeping. +/// +/// Plain additive ADD COLUMN with a NOT NULL default, so no §7 rebuild; +/// column-guarded for a file whose `executions` table was created at the v22 +/// shape by this build's [`EXECUTIONS_DDL`]. +fn migrate_v21_to_v22(tx: &rusqlite::Transaction<'_>) -> Result<()> { + if !column_exists(tx, "executions", "journal_era")? { + tx.execute_batch( + "ALTER TABLE executions ADD COLUMN journal_era INTEGER NOT NULL DEFAULT 0;", + )?; + } + Ok(()) +} + /// v6 → v7: the additive data-plane tables — `tool_calls`, /// `execution_reflection`, and `reflections`. No existing table changes shape. fn migrate_v6_to_v7(tx: &rusqlite::Transaction<'_>) -> Result<()> { @@ -1071,6 +1105,34 @@ mod tests { apply_migration(&mut conn, migrate_v11_to_v12, 12).expect("idempotent"); } + #[test] + fn v22_migration_backfills_every_existing_execution_to_the_pre_rewrite_era() { + // The backfill is the load-bearing half of #1981: a row already at + // rest was written by a build that could not journal a compaction + // rewrite, so era 0 is a fact about it. Reading those rows as the + // current era would raise an integrity alarm on every compacted block + // in the user's history. + let mut conn = Connection::open_in_memory().expect("db"); + conn.execute_batch( + "CREATE TABLE executions (id INTEGER PRIMARY KEY, kind TEXT); + INSERT INTO executions (id, kind) VALUES (1, 'run');", + ) + .expect("v21 schema"); + assert!(!column_exists(&conn, "executions", "journal_era").unwrap()); + + apply_migration(&mut conn, migrate_v21_to_v22, 22).expect("migrate"); + + let era: i64 = conn + .query_row("SELECT journal_era FROM executions WHERE id = 1", [], |r| { + r.get(0) + }) + .expect("stamped"); + assert_eq!(era, 0); + // Idempotent on a file whose executions table was created at the v22 + // shape by this build's DDL. + apply_migration(&mut conn, migrate_v21_to_v22, 22).expect("idempotent"); + } + #[test] fn v13_migration_rekeys_receipts_on_call_seq_preserving_existing_rows_as_worker_calls() { // A v12-shaped file with one recorded worker step. The rebuild must diff --git a/crates/stella-store/src/reconstruct.rs b/crates/stella-store/src/reconstruct.rs index 6c4bcca78..38302a0cf 100644 --- a/crates/stella-store/src/reconstruct.rs +++ b/crates/stella-store/src/reconstruct.rs @@ -24,6 +24,16 @@ //! self-defeating — an alarm that fires on routine housekeeping is an alarm //! nobody reads. //! +//! Which of those two a mismatch is therefore depends on *who wrote the +//! journal*, and that is recorded rather than inferred: [`JournalEra`] is +//! stamped on the execution row when the run begins, and +//! [`Reconstruction::mismatch_severity`] is the one place the two eras are +//! told apart. Every surface that renders a mismatch — `stella inspect`, +//! `stella trace`, the deck's INSPECT overlay, the observatory — reads that +//! verdict rather than deciding for itself, because #1668 had to correct all +//! of them at once and a surface that reasons on its own is the one that will +//! be missed next time. +//! //! # Reconstructable boundary (clean path only) //! //! Byte-exact reconstruction holds for the ordinary turn: system prompt, user @@ -36,7 +46,7 @@ use std::collections::HashMap; use std::fmt::Write as _; -use rusqlite::params; +use rusqlite::{OptionalExtension, params}; use sha2::{Digest, Sha256}; use stella_protocol::{ AgentEvent, CompletionMessage, MessageRole, ToolCall, ToolOutput, ToolResult, @@ -44,6 +54,82 @@ use stella_protocol::{ use crate::{Result, Store}; +/// Which compaction-journaling era wrote an execution's events — the signal +/// that decides whether a digest mismatch is routine housekeeping or a real +/// integrity failure. +/// +/// This is **stamped, not inferred**. The tempting cheap test — "did any +/// `Compaction` event in this execution carry rewrites?" — reads the absence +/// of a record as a statement about the writer, and it is wrong in a case that +/// really happens: an overflow-summary splice +/// (`stella_core::driver::apply_overflow_summary`) compacts and legitimately +/// journals no rewrites at all, so a current-era execution would be read as +/// legacy and a genuine integrity signal would be styled as housekeeping. The +/// column costs a migration; guessing costs the alarm. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum JournalEra { + /// Compaction rewrote tool results in place and told the journal nothing + /// about it (every journal written before #1667/PR #1979). A compacted + /// block can only resolve through the `call_id` fallback, which reaches + /// the pre-compaction bytes — so it mismatches, benignly and forever. + /// + /// The default, and what every row written before schema v22 backfills to: + /// an era this build does not recognise reads as this one, so an + /// unfamiliar stamp can only under-alarm. + #[default] + CompactionUnjournaled, + /// Every compaction pass journals its replacement bytes on the + /// `Compaction` event (#1667), so a compacted block resolves by digest to + /// exactly what the step sent. A mismatch here has no housekeeping + /// explanation left. + CompactionJournaled, +} + +impl JournalEra { + /// The era this build writes. Stamped by + /// [`Store::begin_execution`](crate::Store::begin_execution) onto every + /// execution it opens — a statement about the *writer's* capability, which + /// is the one thing no later reader can recover from the events alone. + pub const CURRENT: Self = Self::CompactionJournaled; + + /// The `executions.journal_era` code for this era. + pub fn code(self) -> i64 { + match self { + Self::CompactionUnjournaled => 0, + Self::CompactionJournaled => 1, + } + } + + /// The era a stored code names. An unrecognised code — a row written by a + /// *newer* build, read here after a downgrade — reads as + /// [`Self::CompactionUnjournaled`], the benign direction: this build does + /// not know what that journal guarantees, so it must not claim an + /// integrity failure on its behalf. + pub fn from_code(code: i64) -> Self { + match code { + 1 => Self::CompactionJournaled, + _ => Self::CompactionUnjournaled, + } + } +} + +/// What a digest mismatch on one reconstruction actually means — the verdict +/// every rendering surface styles from, so none of them has to re-derive it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MismatchSeverity { + /// No block mismatched. Nothing to report. + None, + /// The journal predates compaction journaling its rewrites, so the + /// mismatched bytes are the pre-compaction output of a result that was + /// rewritten in place. Routine; renders as a warning that names compaction + /// as the cause, never as tampering. + Compaction, + /// The journal records every compaction rewrite, so compaction is no + /// longer an available explanation: these bytes are unaccounted for. + /// Renders as a real integrity signal. + Integrity, +} + /// The outcome of reconstructing one step: the rebuilt messages plus the honest /// accounting of anything the fold could not fully vouch for. #[derive(Debug, Clone, PartialEq)] @@ -60,14 +146,35 @@ pub struct Reconstruction { /// compaction rewrite the journal was never told about. Empty on the clean /// path. pub digest_mismatches: Vec, + /// Which compaction-journaling era wrote this execution's journal, read + /// from its `executions` row. Decides what + /// [`Self::digest_mismatches`] *means* — see [`Self::mismatch_severity`]. + pub journal_era: JournalEra, } impl Reconstruction { /// Whether every block resolved and every journal-resolved digest matched — /// the step is a faithful, verified reconstruction of what the model saw. + /// + /// Deliberately era-blind: a mismatch is a mismatch, and a reconstruction + /// with one is not something to vouch for whoever wrote it. The era + /// changes how loudly it is *reported*, never whether it happened. pub fn is_verified(&self) -> bool { self.unresolved.is_empty() && self.digest_mismatches.is_empty() } + + /// What this reconstruction's digest mismatches mean, given who wrote the + /// journal. **The single place the two eras are told apart** — every + /// surface styles from this rather than reasoning about the era itself. + pub fn mismatch_severity(&self) -> MismatchSeverity { + if self.digest_mismatches.is_empty() { + return MismatchSeverity::None; + } + match self.journal_era { + JournalEra::CompactionUnjournaled => MismatchSeverity::Compaction, + JournalEra::CompactionJournaled => MismatchSeverity::Integrity, + } + } } /// `sha256` hex of a string (byte-wise; the sha2 0.11 output does not `LowerHex`). @@ -175,6 +282,7 @@ impl Store { messages, unresolved, digest_mismatches, + journal_era: self.journal_era(execution_id)?, }) } @@ -183,6 +291,22 @@ impl Store { fn journal_preimages(&self, execution_id: i64) -> Result { journal_preimages(&self.lock(), execution_id) } + + /// The era stamped on an execution row. An execution that is not there at + /// all reads as [`JournalEra::CompactionUnjournaled`] for the same reason + /// an unknown code does: nothing is known about that journal, and "nothing + /// known" must never be rendered as an integrity failure. + fn journal_era(&self, execution_id: i64) -> Result { + let code: Option = self + .lock() + .query_row( + "SELECT journal_era FROM executions WHERE id = ?1", + params![execution_id], + |row| row.get(0), + ) + .optional()?; + Ok(code.map_or(JournalEra::default(), JournalEra::from_code)) + } } /// [`Store::journal_preimages`] against a bare connection, so a migration @@ -651,6 +775,169 @@ mod tests { assert_eq!(recon.messages[0].tool_results[0].output, stubbed); } + /// Seed one execution whose single manifest block resolves through the + /// `call_id` fallback to bytes that are NOT its recorded digest — the + /// shape a compaction rewrite leaves behind. Returns its id. + /// + /// Both eras of the witness below share it verbatim, because the whole + /// claim is that *the same unresolvable block* reads differently depending + /// only on who wrote the journal. + fn seed_mismatching_block(store: &Store) -> i64 { + let journaled = ToolOutput::Ok { + content: "the original tool output".into(), + }; + let sent = ToolOutput::Ok { + content: "[tool output evicted to fit context]".into(), + }; + let sent_json = serde_json::to_string(&sent).unwrap(); + + let id = store.begin_execution("run", "p", "z", "m").unwrap(); + store + .record_event( + id, + 0, + &AgentEvent::ToolResult { + call_id: "c1".into(), + output: journaled, + duration_ms: 5, + speculated: false, + }, + ) + .unwrap(); + // No Compaction event carrying the replacement: the block's digest is + // over `sent`, and the only preimage the journal holds under `c1` is + // the pre-compaction output. + store + .record_context_block( + id, + &journal("blk_post", "tool_result", Some("c1"), &sent_json), + ) + .unwrap(); + store + .record_step_manifest( + id, + &StepManifestRow { + turn_instance: 0, + step: 1, + call_seq: 0, + provider: "z".into(), + model: "m".into(), + call_role: "worker".into(), + effective_budget_tokens: 100, + calibration_factor: 1.0, + estimated_input_tokens: 10, + compiled_frame_id: None, + frame_hash: None, + blocks: vec![entry("blk_post", 0)], + }, + ) + .unwrap(); + id + } + + #[test] + fn the_same_mismatch_is_housekeeping_on_a_legacy_journal_and_an_alarm_on_a_current_one() { + // The witness for #1981. #1668 downgraded the mismatch surface to a + // benign warning because a mismatch was ROUTINE: compaction rewrote + // tool results in place and journaled nothing, so every compacted + // block mismatched forever. #1667/PR #1979 closed that — a current + // journal carries the replacement bytes — which makes a mismatch there + // mean something again. Both readings are correct, for different + // journals, so the surface has to tell the two apart. Before this + // change it could not: there was no era signal at all, and both of + // these reconstructions rendered as the same warning. + let store = Store::in_memory().unwrap(); + + let current = seed_mismatching_block(&store); + let legacy = seed_mismatching_block(&store); + // What a row written before schema v22 looks like after the migration + // backfills it: era 0, because the build that wrote it could not have + // journaled a rewrite. + store + .lock() + .execute( + "UPDATE executions SET journal_era = 0 WHERE id = ?1", + params![legacy], + ) + .unwrap(); + + let legacy_recon = store.reconstruct_worker_step(legacy, 0, 1).unwrap(); + let current_recon = store.reconstruct_worker_step(current, 0, 1).unwrap(); + + // Same block, same bytes, same failure — the reconstructions differ in + // nothing except who wrote the journal. + assert_eq!(legacy_recon.digest_mismatches, vec!["blk_post".to_string()]); + assert_eq!( + current_recon.digest_mismatches, + legacy_recon.digest_mismatches + ); + assert_eq!(legacy_recon.messages, current_recon.messages); + assert!(!legacy_recon.is_verified() && !current_recon.is_verified()); + + // ...and yet they must not read the same. + assert_eq!( + legacy_recon.mismatch_severity(), + MismatchSeverity::Compaction, + "a pre-#1667 journal mismatches on ordinary compaction; calling that \ + an integrity failure is the false alarm #1668 removed" + ); + assert_eq!( + current_recon.mismatch_severity(), + MismatchSeverity::Integrity, + "this journal records every compaction rewrite, so compaction is not \ + an available explanation for these bytes" + ); + assert_ne!( + legacy_recon.mismatch_severity(), + current_recon.mismatch_severity() + ); + } + + #[test] + fn a_clean_reconstruction_has_no_severity_to_report_in_either_era() { + // Severity is about mismatches, not about the era: a current-era + // journal with nothing wrong must not acquire an alarm just by being + // current. + let recon = Reconstruction { + messages: Vec::new(), + unresolved: vec!["blk_gap".into()], + digest_mismatches: Vec::new(), + journal_era: JournalEra::CompactionJournaled, + }; + assert_eq!(recon.mismatch_severity(), MismatchSeverity::None); + assert!(!recon.is_verified(), "an unresolved block is still a gap"); + } + + #[test] + fn an_unrecognised_era_code_reads_as_the_oldest_one() { + // A row written by a NEWER build, read here after a downgrade. This + // build cannot know what that journal guarantees, and the honest + // failure direction is to under-alarm rather than to accuse. + assert_eq!(JournalEra::from_code(7), JournalEra::CompactionUnjournaled); + assert_eq!(JournalEra::from_code(0), JournalEra::CompactionUnjournaled); + assert_eq!(JournalEra::from_code(1), JournalEra::CompactionJournaled); + assert_eq!(JournalEra::CURRENT.code(), 1); + assert_eq!(JournalEra::default(), JournalEra::CompactionUnjournaled); + } + + #[test] + fn a_run_this_build_started_is_stamped_as_journaling_its_rewrites() { + // The writer-side half of the era signal: the stamp has to be made + // while this binary is the one talking, because nothing downstream can + // recover it from the events. + let store = Store::in_memory().unwrap(); + let id = store.begin_execution("run", "p", "z", "m").unwrap(); + let stamped: i64 = store + .lock() + .query_row( + "SELECT journal_era FROM executions WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(JournalEra::from_code(stamped), JournalEra::CURRENT); + } + #[test] fn a_block_with_no_journal_preimage_surfaces_as_unresolved_not_a_lie() { // A tool_result block whose ToolResult event is absent (the deferred diff --git a/crates/stella-store/src/tests.rs b/crates/stella-store/src/tests.rs index 0b20f7f64..10a84ee24 100644 --- a/crates/stella-store/src/tests.rs +++ b/crates/stella-store/src/tests.rs @@ -1107,8 +1107,8 @@ fn skill_usage_records_per_execution_version_rows() { // `session_turn_diffs` (#1870): per (session, turn), the precomputed // workspace diff the work journal's turn marks describe, so the // observatory can replay file changes without opening the bare repo. - // Additive. - assert_eq!(SCHEMA_VERSION, 21); + // Additive. v22 adds `executions.journal_era` (#1981) — see `JournalEra`. + assert_eq!(SCHEMA_VERSION, 22); let id = store .begin_execution("deck", "format the sql", "zai", "glm-5.2") diff --git a/crates/stella-tui/src/deck_render.rs b/crates/stella-tui/src/deck_render.rs index 9440668a3..a5c94de43 100644 --- a/crates/stella-tui/src/deck_render.rs +++ b/crates/stella-tui/src/deck_render.rs @@ -768,9 +768,8 @@ fn render_inspect_overlay(ui: &mut DeckUi, area: Rect, buf: &mut Buffer) { theme::muted(), ))); // Never merged: unresolved is a coverage gap, a mismatch means the - // recovered bytes are not this block's. Neither is phrased as tampering - // — the common cause is a compaction rewrite the journal was never told - // about, and an alarm that fires on housekeeping is one nobody reads. + // recovered bytes are not this block's. The gap is never phrased as + // tampering — it is a documented coverage boundary, not a signal. if view.unresolved > 0 { lines.push(Line::from(Span::styled( format!( @@ -781,14 +780,13 @@ fn render_inspect_overlay(ui: &mut DeckUi, area: Rect, buf: &mut Buffer) { Style::default().fg(theme::WARN), ))); } - if view.digest_mismatches > 0 { - // Kept short enough to survive the overlay's clip: the cause is the - // whole point of the line, so it must not fall off the right edge. - let n = view.digest_mismatches; - lines.push(Line::from(Span::styled( - format!(" ! {n} block(s) did not re-hash — showing closest preimage, likely a compaction rewrite"), - Style::default().fg(theme::WARN), - ))); + // A mismatch means one of two things depending on who wrote the + // journal, and `InspectView` (not this renderer) holds that verdict — + // see `envelope::InspectView::digest_mismatch_line`, which also keeps + // both variants short enough to survive this overlay's clip (#1981). + if let Some((text, alarm)) = view.digest_mismatch_line() { + let tone = if alarm { theme::DANGER } else { theme::WARN }; + lines.push(Line::from(Span::styled(text, Style::default().fg(tone)))); } if view.verified { lines.push(Line::from(Span::styled( diff --git a/crates/stella-tui/src/deck_render/tests.rs b/crates/stella-tui/src/deck_render/tests.rs index 61cc0aae6..fc7411a9f 100644 --- a/crates/stella-tui/src/deck_render/tests.rs +++ b/crates/stella-tui/src/deck_render/tests.rs @@ -590,6 +590,7 @@ fn inspect_overlay_scroll_saturates_past_u16_instead_of_wrapping() { verified: false, unresolved: 0, digest_mismatches: 0, + journal_era: crate::envelope::JournalEra::CompactionJournaled, })); ui.inspect_scroll = 66_000; diff --git a/crates/stella-tui/src/deck_ui/tests/help.rs b/crates/stella-tui/src/deck_ui/tests/help.rs index 1f25f3d3e..898714bbc 100644 --- a/crates/stella-tui/src/deck_ui/tests/help.rs +++ b/crates/stella-tui/src/deck_ui/tests/help.rs @@ -148,6 +148,7 @@ fn esc_steps_back_from_the_detail_to_the_list_before_closing() { verified: true, unresolved: 0, digest_mismatches: 0, + journal_era: crate::envelope::JournalEra::CompactionJournaled, })), &mut WorkspaceModel::new(), &mut ui, diff --git a/crates/stella-tui/src/envelope.rs b/crates/stella-tui/src/envelope.rs index 738283e73..24f219cfc 100644 --- a/crates/stella-tui/src/envelope.rs +++ b/crates/stella-tui/src/envelope.rs @@ -441,6 +441,27 @@ pub struct InspectMessage { pub content: String, } +/// Which compaction-journaling era wrote the journal a reconstruction came +/// from — the deck's mirror of `stella_store::JournalEra` (this crate links no +/// store; the driver maps one to the other, exactly as it does for every other +/// type on this envelope). +/// +/// It exists here for one reason: it decides whether a digest mismatch is +/// routine housekeeping or a real integrity signal, and the overlay must not +/// guess (#1981). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum JournalEra { + /// Compaction rewrote tool results in place and journaled nothing about it + /// (#1667 and earlier), so a compacted block mismatches as a matter of + /// course. The default: an era the deck was not told reads as the one + /// where a mismatch means the least. + #[default] + CompactionUnjournaled, + /// Every compaction rewrite is journaled, so a compacted block resolves to + /// exactly what the step sent and a mismatch has no routine explanation. + CompactionJournaled, +} + /// The reconstructed context of one model call — what the INSPECT overlay /// shows. Carries the verification verdict alongside the bytes, because a /// transcript you cannot vouch for is worse than none. @@ -455,10 +476,47 @@ pub struct InspectView { pub unresolved: usize, /// Blocks whose recovered bytes did NOT re-hash to the digest they /// recorded — the preimage shown is the closest one the journal holds, not - /// the exact bytes this step sent. Usually a compaction rewrite the journal - /// was never told about, not evidence of tampering. Kept distinct from - /// `unresolved`: the two mean very different things. + /// the exact bytes this step sent. Kept distinct from `unresolved`: the two + /// mean very different things. What a mismatch *means* depends on + /// [`Self::journal_era`] — see [`Self::digest_mismatch_line`]. pub digest_mismatches: usize, + /// Who wrote the journal these blocks were resolved from. + pub journal_era: JournalEra, +} + +impl InspectView { + /// The overlay's mismatch line and whether it is a genuine integrity + /// signal, or `None` when nothing mismatched. + /// + /// The wording lives here rather than in the renderer for two reasons. + /// `deck_render.rs` is a god file at its ceiling, so a second branch there + /// costs lines it does not have; and the overlay **clips** rather than + /// wraps, so each variant has to be short enough to survive the right edge + /// intact — which is a property of the string, testable here, not of the + /// draw call. Both variants stay under 80 columns and put the *meaning* + /// before the detail, so the part that distinguishes them survives even on + /// the narrowest terminal the popup can be drawn in. + /// + /// A legacy journal's mismatch stays the benign warning #1668 introduced, + /// naming compaction as the cause. A journal that records every rewrite + /// has no such excuse, so the same mismatch reads as the alarm it is + /// (#1981). + pub fn digest_mismatch_line(&self) -> Option<(String, bool)> { + let n = self.digest_mismatches; + if n == 0 { + return None; + } + Some(match self.journal_era { + JournalEra::CompactionUnjournaled => ( + format!(" ! {n} block(s) did not re-hash — an older journal's compaction rewrite"), + false, + ), + JournalEra::CompactionJournaled => ( + format!(" !! {n} block(s) unaccounted for — this journal records its rewrites"), + true, + ), + }) + } } /// One row of the ISSUES tab's browse list — a tracker-agnostic mirror of @@ -1370,6 +1428,39 @@ mod tests { } } + /// The INSPECT overlay clips rather than wraps, and its popup is + /// `min(frame - 6, 120)` columns wide — so on an 80-column terminal a + /// banner line has about 72 to work with. Both variants are written to + /// that budget and put the meaning first; this pins it, because a wording + /// change that silently pushes the distinguishing half off the right edge + /// would undo #1981 without failing anything else (see #2029). + #[test] + fn both_mismatch_lines_survive_an_eighty_column_terminal() { + for era in [ + JournalEra::CompactionUnjournaled, + JournalEra::CompactionJournaled, + ] { + let view = InspectView { + call: RecordedCallInfo { + turn_instance: 0, + step: 1, + call_seq: 0, + call_role: "worker".into(), + provider: "zai".into(), + model: "glm-5.2".into(), + estimated_input_tokens: 10, + }, + messages: Vec::new(), + verified: false, + digest_mismatches: 999, + unresolved: 0, + journal_era: era, + }; + let (line, _) = view.digest_mismatch_line().expect("a mismatch is reported"); + assert!(line.chars().count() <= 72, "{} cols: {line}", line.len()); + } + } + #[test] fn meta_builder_sets_fields() { let m = AgentMeta::new("lead", "acme-api", 1000) diff --git a/crates/stella-tui/src/lib.rs b/crates/stella-tui/src/lib.rs index 17d428f5c..438aa6a98 100644 --- a/crates/stella-tui/src/lib.rs +++ b/crates/stella-tui/src/lib.rs @@ -116,8 +116,8 @@ pub use deck_ui::{ pub use envelope::{ AgentControl, AgentId, AgentMeta, AgentScope, AgentStatus, AgentVersionInfo, EngineAgentState, EngineConfigState, EngineRole, EntityField, EntityHit, Inbound, InspectMessage, InspectView, - InstalledAgentEntry, IssueAction, IssueRow, McpLiveIdentity, McpLookupState, McpSearchItem, - McpSearchOutcome, McpServerDetail, McpServerInfo, McpToolRow, NotificationInfo, + InstalledAgentEntry, IssueAction, IssueRow, JournalEra, McpLiveIdentity, McpLookupState, + McpSearchItem, McpSearchOutcome, McpServerDetail, McpServerInfo, McpToolRow, NotificationInfo, RecordedCallInfo, Secret, SessionInfo, SessionPhase, SkillOp, SkillRow, SkillScope, SkillSearchHit, SkillsView, SplashCue, ToolDenial, ToolPolicyState, ToolRow, ToolScope, WorkspaceInput, diff --git a/crates/stella-tui/tests/deck_snapshot.rs b/crates/stella-tui/tests/deck_snapshot.rs index 79972948e..4c3603802 100644 --- a/crates/stella-tui/tests/deck_snapshot.rs +++ b/crates/stella-tui/tests/deck_snapshot.rs @@ -492,7 +492,7 @@ fn help_overlay_shows_only_the_active_tabs_shortcuts() { /// if the prompt bytes are not on the screen, the feature does not work. #[test] fn inspect_overlay_renders_the_call_list_then_the_context_sent() { - use stella_tui::{InspectMessage, InspectView, RecordedCallInfo}; + use stella_tui::{InspectMessage, InspectView, JournalEra, RecordedCallInfo}; let model = folded_model(); let call = |step: u64, call_seq: u64, role: &str| RecordedCallInfo { @@ -549,6 +549,7 @@ fn inspect_overlay_renders_the_call_list_then_the_context_sent() { verified: true, unresolved: 0, digest_mismatches: 0, + journal_era: JournalEra::CompactionJournaled, })); let detail = render(&mut ui); assert!(detail.contains("context sent"), "titled:\n{detail}"); @@ -565,30 +566,57 @@ fn inspect_overlay_renders_the_call_list_then_the_context_sent() { "the verdict is shown:\n{detail}" ); - // A digest mismatch must read differently from a coverage gap. + // A digest mismatch must read differently from a coverage gap — and, + // since #1981, differently again depending on which era wrote the journal. + // Two renders of the SAME mismatch follow; everything but the era is held + // equal, so any difference between them is the era's doing. if let Some(view) = ui.inspect_view.as_mut() { view.verified = false; view.digest_mismatches = 2; + view.journal_era = JournalEra::CompactionUnjournaled; } - let mismatched = render(&mut ui); + let legacy = render(&mut ui); assert!( - mismatched.contains("did not re-hash"), - "a digest mismatch is called out, not folded into 'unverified':\n{mismatched}" + legacy.contains("did not re-hash"), + "a digest mismatch is called out, not folded into 'unverified':\n{legacy}" ); - // ...and it must NOT read as tampering. The common cause is a compaction - // rewrite the journal never learned about, so the line names that instead - // of accusing the journal of being torn or altered. Pinned because the - // wording IS the feature here: an integrity alarm that fires on ordinary - // housekeeping is one the reader learns to skip. + // On a journal written before compaction recorded its rewrites (#1667), + // this mismatch is what ordinary housekeeping looks like, so the line must + // NOT read as tampering. Pinned because the wording IS the feature here: + // an integrity alarm that fires on routine work is one the reader learns + // to skip — the false alarm #1668 removed. for accusation in ["torn", "altered", "tamper"] { assert!( - !mismatched.contains(accusation), - "the mismatch line must not imply tampering, found {accusation:?}:\n{mismatched}" + !legacy.contains(accusation), + "the legacy mismatch line must not imply tampering, found {accusation:?}:\n{legacy}" ); } assert!( - mismatched.contains("compaction rewrite"), - "the likely benign cause is named:\n{mismatched}" + legacy.contains("compaction rewrite"), + "the benign cause is named:\n{legacy}" + ); + + // The same mismatch on a journal that records every compaction rewrite has + // no housekeeping explanation left, so it must stop reading as one. This + // half is the #1981 witness at the render surface: before it, both eras + // produced the line asserted above. + if let Some(view) = ui.inspect_view.as_mut() { + view.journal_era = JournalEra::CompactionJournaled; + } + let current = render(&mut ui); + assert_ne!( + legacy, current, + "the same mismatch must not render identically in both eras — that is \ + the whole of #1981:\n{current}" + ); + assert!( + current.contains("unaccounted for"), + "a mismatch on a rewrite-journaling journal reads as an integrity \ + signal:\n{current}" + ); + assert!( + !current.contains("older journal"), + "…and must not offer the excuse this journal does not have:\n{current}" ); } diff --git a/docs/spec/session-telemetry-receipts-spec.md b/docs/spec/session-telemetry-receipts-spec.md index bd1fcecf1..83fbb869d 100644 --- a/docs/spec/session-telemetry-receipts-spec.md +++ b/docs/spec/session-telemetry-receipts-spec.md @@ -292,8 +292,14 @@ To reconstruct exactly what step *N* of turn *T* saw: 2. For each `ManifestEntry.block_id`, resolve the preimage from its `BlockRegistered.origin` event (the `ToolResult` / `Text` / recalled-frame content already in the journal — §5.3 for recall frames). -3. Verify each preimage against `content_digest`. A mismatch is a torn-journal or - tampering signal, surfaced by the inspector. +3. Verify each preimage against `content_digest`. What a mismatch *means* + depends on the era of the journal, which the execution row records + (`executions.journal_era`, schema v22): on a journal written before + compaction journaled its replacement bytes (#1667), a compacted block + resolves only through the `call_id` fallback and mismatches as a matter of + course; on a journal that records every rewrite, nothing routine accounts + for it and it is a torn-journal or tampering signal. The inspector surfaces + both, styled differently (#1981). 4. Concatenate in manifest order → the byte-exact `CompletionRequest.messages`. No new content store is read; the manifest is an index over the fold. diff --git a/website/content/docs/commands/inspect.mdx b/website/content/docs/commands/inspect.mdx index e125e5df8..14125c9f2 100644 --- a/website/content/docs/commands/inspect.mdx +++ b/website/content/docs/commands/inspect.mdx @@ -166,10 +166,16 @@ Two failure modes are reported separately, because they mean very different thin speculation, or attachments. The rest of the transcript is still faithful. - The journal is torn or was altered. Treat the reconstruction as untrustworthy. + Nothing routine accounts for these bytes. Treat the reconstruction as untrustworthy. + + + An older journal. Compaction rewrote a tool result in place without recording + the replacement, so replay recovers the pre-compaction bytes. Ordinary. +The last two are the same failure read against different journals, and which one you get is not a guess: every execution records the journal era its writer could produce. A journal written before compaction recorded its rewrites mismatches on every compacted block, forever — reporting that as an integrity breach would be an alarm that fires on routine housekeeping, so it does not. A journal that records every rewrite has no such explanation available, and the alarm is the honest reading. `--format json` reports both halves as `journal_era` (`compaction_journaled` / `compaction_unjournaled`) and `digest_mismatch_severity` (`none` / `compaction` / `integrity`). + The system prefix and the assembled user message are stored as local bytes rather than resolved from the journal, so their digest check is tautological and is deliberately **not** counted as evidence. The proof lives in the journal-resolved kinds. ## Examples @@ -198,12 +204,12 @@ Read the verifier's prompt instead of the worker's — the auxiliary calls at th stella inspect 42 --step 3 --call-seq 2 --full ``` -Check the verdict from a script without reading the transcript — `verified`, plus the two -failure lists behind it: +Check the verdict from a script without reading the transcript — `verified`, the two +failure lists behind it, and what a mismatch means on this journal: ```bash stella inspect 42 --step 3 --format json | - jq '{verified, unresolved, digest_mismatches}' + jq '{verified, unresolved, digest_mismatches, digest_mismatch_severity}' ``` Assert in CI that the system prompt did not drift between turns — `base` reports diff --git a/website/content/docs/guides/what-a-run-cost.mdx b/website/content/docs/guides/what-a-run-cost.mdx index 0d918a83f..91fd81267 100644 --- a/website/content/docs/guides/what-a-run-cost.mdx +++ b/website/content/docs/guides/what-a-run-cost.mdx @@ -140,10 +140,18 @@ they mean very different things: speculation, attachments. The rest of the transcript is still faithful. - The journal is torn or was altered. Treat the reconstruction as untrustworthy. + Nothing routine accounts for these bytes. Treat the reconstruction as untrustworthy. + + + An older journal, where compaction rewrote a tool result in place without + recording the replacement. Ordinary, not a signal. +The last two are the same failure on different journals, and the era each execution +was written in is recorded rather than guessed — see +[`stella inspect`](/docs/commands/inspect#reading-the-verification-banner). + ## 5. What changed since the previous call — `--diff` This is the step that actually explains a cache regression, and it is the one @@ -226,7 +234,7 @@ stella inspect 42 --step 0 --diff --only system --format json | # Is the reconstruction trustworthy at all? stella inspect 42 --step 3 --format json | - jq '{verified, unresolved, digest_mismatches}' + jq '{verified, unresolved, digest_mismatches, digest_mismatch_severity}' ``` ## The other cost question: was it worth it? diff --git a/website/content/docs/telemetry/dashboard.mdx b/website/content/docs/telemetry/dashboard.mdx index 8caef9db8..a3812f6d5 100644 --- a/website/content/docs/telemetry/dashboard.mdx +++ b/website/content/docs/telemetry/dashboard.mdx @@ -46,7 +46,9 @@ panels labeled *all-time* ignore it. files touched, tokens, and cost. Clicking a row opens a drill-down drawer with per-step tokens and latency, **context sent** — the message array each recorded model call was given, system prompt included, rebuilt from that call's context receipt and checked - against the digests recorded when it was emitted (what `stella inspect` prints) — the + against the digests recorded when it was emitted (what `stella inspect` prints, with + the same two readings of a failed check: routine on a journal written before + compaction recorded its rewrites, a real integrity signal on one written since) — the **transcript** replayed from the run's event journal — answer text, reasoning, and each tool call's arguments and output, with long bodies clipped until you ask for them in full — every tool call, the files touched, and the post-turn self-reflection.