Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/stella-cli/src/command_deck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})));
});
}
Expand Down
170 changes: 156 additions & 14 deletions crates/stella-cli/src/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 \
Expand All @@ -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");
Expand All @@ -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<String> {
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 {
Expand Down Expand Up @@ -900,9 +925,49 @@ struct ReconstructionJson {
verified: bool,
unresolved: Vec<String>,
digest_mismatches: Vec<String>,
/// 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<MessageJson>,
}

/// 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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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");
Expand Down
9 changes: 8 additions & 1 deletion crates/stella-cli/src/trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")
)
});
Expand Down
6 changes: 6 additions & 0 deletions crates/stella-cli/tests/inspect_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 4 additions & 2 deletions crates/stella-observatory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
39 changes: 31 additions & 8 deletions crates/stella-observatory/src/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1493,24 +1493,45 @@ <h2 id="drawerTitle">${esc(d.prompt)}</h2>
<div id="ctx-body"></div></div>`;
}
/* 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 `<div class="kick" style="color:var(--ok)">✓ verified · every
journal-resolved block re-hashed to its recorded digest</div>`;
}
const bad = (c.digest_mismatches ?? []).length;
const gaps = (c.unresolved ?? []).length;
return `${bad ? `<div class="kick" style="color:var(--bad)">✕ ${fmtInt(bad)} block(s)
did NOT re-hash to their recorded digest — the journal is torn or was altered</div>` : ""}
const integrity = c.digest_mismatch_severity === "integrity";
const mismatch = integrity
? `<div class="kick" style="color:var(--bad)">✕ ${fmtInt(bad)} block(s) did NOT re-hash to
their recorded digest — this journal records every compaction rewrite, so these bytes
are unaccounted for</div>`
: `<div class="kick" style="color:var(--warn)">! ${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</div>`;
return `${bad ? mismatch : ""}
${gaps ? `<div class="kick" style="color:var(--warn)">◌ ${fmtInt(gaps)} block(s) could not
be resolved (synthetic results, discarded speculation, or attachments)</div>` : ""}`;
}
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 `<div class="jrnl${torn ? " err" : ""}">
<div class="kick">[${fmtInt(m.index)}] ${esc(m.role)} · ${fmtInt(blocks.length)} block${
blocks.length === 1 ? "" : "s"}${
Expand All @@ -1536,7 +1557,9 @@ <h2 id="drawerTitle">${esc(d.prompt)}</h2>
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)
? `<button type="button" class="jrnl-full" id="ctx-full">show full bodies</button>` : ""}`;
const btn = $("ctx-full");
Expand Down
22 changes: 19 additions & 3 deletions crates/stella-observatory/src/context_diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
),
})
}

Expand Down
Loading