feat(mcp): books.journal_history, the journal in words (P5)

Agent reason: an agent traces a figure back to what produced it by reading
the journal, but it must never read the file. books.journal_history is the
one way: each line in order with its seq, time, type, a sentence saying what
it did to what, its subject and the entry it belongs to, filtered by
after_seq, type (exact, or a group like entry.) and subject (entry:12,
invoice:3, bill:3, account:10, party:5, asset:2), paged with more, with the
torn tails set aside on open listed beside them. It reads the verified
journal through read_journal_and_format, so a damaged chain is an error, not
a story.

The sentences come from ledger-store's describe.rs, which quotes only
identifiers, dates, amounts and the short names a person typed: never a
setting's value (settings.set names its keys) and never a full TIN (masked,
B-26). Every event type the store writes has a sentence (tested against
EVENT_TYPES). The tool is read-only (books:read); the books://journal/{seq}
and books://entries/{id} resources read through it.

Departure from the plan: the viewer's own sentences (its describe arms in
ledger-gui, which carry icons, routes and today's party and account names)
stay where they are; moving a thousand lines of egui text behind a
formatting trait was not worth the risk to the viewer's tests, so the server
has the shared describer and the viewer keeps its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:48:27 -06:00
1 parent 4236f03744
commit 61da9e57cd
8 files changed
+594 -3

No files matched your search

+3 -1
View File
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 133] = [
pub const TABLE: [(&str, Needs); 134] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
@@ -200,6 +200,8 @@ pub const TABLE: [(&str, Needs); 133] = [
("books.list_drafts", R),
("books.set_draft_state", W),
("books.discard_draft", C),
// P5
("books.journal_history", R),
("books.preview_entry", R),
];
+31
View File
@@ -584,6 +584,7 @@ impl BooksServer {
+ Self::tax_router()
+ Self::write_offs_router()
+ Self::drafts_router()
+ Self::journal_router()
}
/// The settings in force for this request: the books' own, taken from
@@ -1403,6 +1404,8 @@ mod backup_tools;
mod files;
#[path = "tools/instruments.rs"]
mod instruments;
#[path = "tools/journal.rs"]
mod journal_tools;
#[path = "tools/kept.rs"]
mod kept;
#[path = "tools/projects.rs"]
@@ -1539,6 +1542,34 @@ impl BooksServer {
.await?;
json(&out)
}
_ if path.starts_with("books://entries/") => {
let id: u64 = path["books://entries/".len()..]
.parse()
.map_err(|_| format!("bad entry resource URI: {uri}"))?;
let Json(out) = self
.get_entry(Parameters(GetEntryParams { entry_id: id }))
.await?;
json(&out)
}
_ if path.starts_with("books://journal/") => {
let seq: u64 = path["books://journal/".len()..]
.parse()
.map_err(|_| format!("bad journal resource URI: {uri}"))?;
if seq == 0 {
return Err("journal seqs start at 1".into());
}
let Json(out) = self
.journal_history(Parameters(journal_tools::JournalHistoryParams {
after_seq: Some(seq - 1),
limit: Some(1),
..Default::default()
}))
.await?;
if out.lines.first().is_none_or(|l| l.seq != seq) {
return Err(format!("the journal has no line {seq}"));
}
json(&out)
}
handbook::URI => {
let Json(out) = self.handbook(Parameters(HandbookParams::default())).await?;
json(&out)
+2 -1
View File
@@ -42,7 +42,7 @@ fn annotations_complete() {
const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), Some(true));
let table: [(&str, Hints); 133] = [
let table: [(&str, Hints); 134] = [
("books.handbook", R),
("books.start", R),
("books.set_setting", DI),
@@ -178,6 +178,7 @@ fn annotations_complete() {
("books.list_drafts", R),
("books.set_draft_state", W),
("books.discard_draft", D),
("books.journal_history", R),
("books.preview_entry", R),
];
let tools = BooksServer::tool_router().list_all();
+137
View File
@@ -0,0 +1,137 @@
//! `books.journal_history`: the journal in words (plan P5 step 4).
//!
//! ```text
//! GOAL: an agent can trace what the books did, line by line, without the
//! journal file: the only way to "read the journal".
//! SOURCE: plan/served.md 4.3 (tracing figures), P5 step 4; the describer
//! is ledger_store::describe.
//! INVARIANTS:
//! 1. Read-only; needs books:read; at most 200 lines a call, 50 by
//! default, in journal order from the line after `after_seq`.
//! 2. A sentence quotes no setting value and no full TIN
//! (ledger_store::describe INVARIANT 2).
//! 3. `subject` is `kind:id`; a bad one is refused with the shapes.
//! ```
use super::*;
use ledger_store::describe::{HistoryFilter, parse_subject};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// The most lines one call returns.
const HISTORY_MAX: usize = 200;
/// The lines a call returns when it names no limit.
const HISTORY_DEFAULT: usize = 50;
/// Parameters for `books.journal_history`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct JournalHistoryParams {
/// Only lines after this journal seq (default: from the first).
#[serde(default)]
pub after_seq: Option<u64>,
/// Only these line types: an exact type (`entry.posted`) or a group
/// ending in a dot (`entry.`).
#[serde(default)]
pub types: Vec<String>,
/// Only lines about one subject: `entry:12`, `invoice:3`, `bill:3`,
/// `account:10`, `party:5` or `asset:2`.
#[serde(default)]
pub subject: Option<String>,
/// At most this many lines (default 50, most 200).
#[serde(default)]
pub limit: Option<u32>,
}
/// One journal line, in words.
#[derive(Debug, Serialize, JsonSchema)]
pub struct JournalLineOut {
/// Its seq.
pub seq: u64,
/// When it was written (UTC).
pub ts: String,
/// Its type, e.g. `entry.posted`.
#[serde(rename = "type")]
pub line_type: String,
/// What it did, in a sentence.
pub summary: String,
/// What it is about, as `kind:id`, if it is about one thing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
/// The entry it belongs to, if it is about one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_id: Option<u64>,
}
/// A torn journal tail set aside when the books opened.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TailRepairOut {
/// When (UTC).
pub at: String,
/// How many bytes were set aside.
pub bytes: u64,
}
/// Result of `books.journal_history`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct JournalHistoryOut {
/// The lines, in journal order.
pub lines: Vec<JournalLineOut>,
/// Whether more lines match after the last (ask again with its seq as
/// after_seq).
pub more: bool,
/// The journal's last seq.
pub head_seq: u64,
/// Torn tails set aside on open, oldest first.
pub tail_repairs: Vec<TailRepairOut>,
}
#[tool_router(router = journal_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.journal_history",
title = "Journal history",
description = "The journal in words, in order: each line's seq, time, type and a sentence saying what it did, to what, and the entry it belongs to. Filter by after_seq, types (an exact type like entry.posted, or a group like entry.) and subject (entry:12, invoice:3, bill:3, account:10, party:5, asset:2). The way to trace a figure back to what produced it, and the only way to see what the journal holds. Read-only; at most 200 lines a call.",
annotations(read_only_hint = true, open_world_hint = false)
)]
pub(super) async fn journal_history(
&self,
params: Parameters<JournalHistoryParams>,
) -> Result<Json<JournalHistoryOut>, String> {
let p = params.0;
let subject = p
.subject
.as_deref()
.map(|s| parse_subject(s).map(|(k, id)| format!("{k}:{id}")))
.transpose()?;
let limit = (p.limit.unwrap_or(HISTORY_DEFAULT as u32) as usize).clamp(1, HISTORY_MAX);
let filter = HistoryFilter {
after_seq: p.after_seq.unwrap_or(0),
types: p.types,
subject,
limit,
};
let store = self.lock()?;
let h = store.journal_history(&filter).map_err(|e| e.to_string())?;
Ok(Json(JournalHistoryOut {
lines: h
.lines
.into_iter()
.map(|d| JournalLineOut {
seq: d.seq,
ts: d.ts,
line_type: d.line_type,
summary: d.summary,
subject: d.subjects.first().cloned(),
entry_id: d.entry_id,
})
.collect(),
more: h.more,
head_seq: h.head_seq,
tail_repairs: h
.tail_repairs
.into_iter()
.map(|(at, bytes)| TailRepairOut { at, bytes })
.collect(),
}))
}
}