fix(mcp): a read says only what follows; a success adds no empty warnings (P5)

Two existing contracts the envelope broke when every result got every field:
a resource and its tool say the same (books://settings is books.settings,
checked by the_kept_resources_read), and a tool with no warnings has no
warnings key (books.add_account, defect 8). So head and next_steps are on
every tool that can change the books, next_steps is on a read only when
something follows it, guards is on a refusal, and the envelope adds no
warnings of its own (a tool's stay as they are).

The happy-path spec test now exercises books.journal_history (it fails on
a tool it never called), the demo company gets its books.start size check
(Done when 3), and the As-built block of P5 is written.

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:42 -06:00
1 parent 8e683942a2
commit ad1afb478d
6 files changed
+76 -11

No files matched your search

+43 -2
View File
@@ -429,8 +429,8 @@ fn books_start_v2_is_whole_and_fits() {
assert!(s["why"].is_string() && s["required"].is_boolean(), "{s}");
assert!(s["tool"].is_string() || s["prompt"].is_string(), "{s}");
}
// The envelope: a read has steps and no head.
assert!(start.get("guards").is_some_and(Value::is_array));
// The envelope: a read has no head of the envelope's (its own stays).
assert!(start["head"]["seq"].is_u64());
// The self-report is in the access log, beside the call.
let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap();
@@ -502,3 +502,44 @@ fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
assert_eq!(guard["code"], "stale_preview", "{refused}");
assert_eq!(guard["remedy_tool"], "books.post_entry");
}
/// Done-when (3): `books.start` is at most 8 KiB on the demo company too
/// (the books the viewer ships with, a loose end of every kind), and says
/// what it cut.
#[test]
fn books_start_fits_8_kib_on_the_demo_books() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("p5-start-demo");
let source = tempfile::Builder::new()
.prefix("wb-p5-demo-")
.tempdir()
.unwrap();
ledger_gui::demo::build(source.path(), today).unwrap();
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
let mut wire = place.start(None);
wire.initialize("p5");
let raw = wire.call("books.start", json!({}));
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.len() <= 8 * 1024,
"books.start is {} bytes",
text.len()
);
let start = answer_of(&raw).unwrap();
assert_eq!(start["state"], "active");
assert!(
!start["needs_attention"]["items"]
.as_array()
.unwrap()
.is_empty(),
"the demo has loose ends: {start}"
);
assert!(
start["needs_attention"]["counts"]
.as_object()
.unwrap()
.len()
>= 2
);
}
+2
View File
@@ -1294,6 +1294,8 @@ fn happy_path(name: &str) -> Mcp {
// The access log, the backups' status, and a backup now (none is set
// up: it says so).
m.ok("books.access_log", json!({}));
// P5: the journal in words.
m.ok("books.journal_history", json!({"limit": 5}));
m.ok("books.backup", json!({}));
m.ok("books.back_up_now", json!({}));
+11 -4
View File
@@ -12,7 +12,10 @@
//! 1. The envelope is applied in one place (`apply`, called by
//! `call_tool`), so no tool can forget it.
//! 2. Every result of a tool that is not read-only has a `head` (the
//! journal after the call's last write) and at least one next step.
//! journal after the call's last write) and at least one next step; a
//! read has `next_steps` when something follows it and nothing else (a
//! resource and its tool agree). `guards` is on a refusal, and a
//! success without warnings has no `warnings` key.
//! 3. A tool's own fields are never overwritten: a payload that already
//! has `next_steps`, `guards`, `warnings` or `head` keeps it.
//! 4. A refused call has no head and no steps; a refusal that is a
@@ -451,14 +454,18 @@ pub(crate) fn apply(
if mutating && steps.is_empty() {
steps.push(digest_step());
}
if !body.contains_key("next_steps") {
// A tool that can change the books always says what follows; a read
// says it only when something does, so what it says is what a resource
// of the same name says (`books://settings` is `books.settings`).
// `guards` is on a refusal (above) and `warnings` is the tool's own:
// a success that has none says none, as the tools always did.
if !body.contains_key("next_steps") && (mutating || !steps.is_empty()) {
body.insert(
"next_steps".into(),
Value::Array(steps.iter().map(Step::to_json).collect()),
);
}
body.entry("guards").or_insert_with(|| json!([]));
body.entry("warnings").or_insert_with(|| json!([]));
if let Some(head) = head {
body.entry("head").or_insert(head);
}
+2 -2
View File
@@ -1564,8 +1564,8 @@ fn every_mutating_result_has_head_and_next_steps() {
};
let body = r.structured_content.expect("structured");
assert!(
body["guards"].is_array() && body["warnings"].is_array(),
"{name}"
body.get("guards").is_none() && body.get("warnings").is_none(),
"{name}: a success without them says none"
);
let ContentBlock::Text(t) = &r.content[0] else {
panic!("{name}: no text block");