Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
20 KiB
M5c-1 task 07: memoryd's Honcho ledger, memory/honcho.jsonl
Branch: m5c1 (run git switch m5c1; git status --short must be empty, otherwise stop)
Commit subject: memoryd: the Honcho ledger, decided, sent and failed, read through at start
Log row: the Task cell is M5c1/07-memoryd-honcho-ledger.
Goal
From M5c on, memoryd sends finished turns and memory writes to Honcho (M5c spec, section 5).
Each one is first decided (where it goes, or that it goes nowhere), then sent or
failed (Honcho refused it). This task adds the record of that: the ledger, an append-only file
of JSON lines, <home>/memory/honcho.jsonl. What is decided and neither sent nor failed is the
outbox, the work still to do; there is no other queue. memoryd reads the ledger through when
it starts, so nothing is decided twice or sent twice.
This task is the module memoryd::honcho::ledger alone. Nothing calls it yet: task 09 opens it
at start and decides turns into it, later tasks send from its outbox. Its path is passed in.
- Items. A turn of a session (
Item::Turn { session, turn }), or one part of a journalled memory edit (Item::Edit { id, part }; the id is the journal's,2026-09-28-0001). - Actions (what a
Decidedrecord says): the turn's messages to a workspace; a conclusion to create or delete; a message, or its retraction, in the fleet workspace; orSkip, nothing to send, and why. A turn's text is never stored here: the sender reads it from the session log. - The rules. An item has exactly one
Decidedrecord. It has at most oneSentorFailedrecord, and only while it is waiting: decided, notSkip, and neither sent nor failed yet.appendrefuses a record that breaks a rule, and writes nothing for it. - Durable before believed.
appendwrites the line, syncs it to disk, and only then takes it into the ledger's state in memory, asloopddoes with its session log. A failed write leaves the state as it was, so the ledger never says an item is decided or sent when the file does not. - Reading through.
openreads the whole file and replays it. A missing file is an empty ledger. A last line with no newline was cut off by a crash in the middle of an append: it is dropped and cut from the file, andrepaired()says so. Any other line that does not parse, or that breaks a rule when replayed in order, stops the ledger:LedgerError::Damagedwith its line number and the runbook pointerhoncho-ledger-damaged, and the file is left as it is.
Interfaces
Later tasks use these exactly as written. They are in the skeleton you copy (step 3); only 12 private or small function bodies are yours to fill.
In crates/memoryd/src/honcho/ledger.rs (the module memoryd::honcho::ledger):
pub const RUNBOOK: &str = "see docs/runbook.md#honcho-ledger-damaged";
pub enum Workspace { Trusted, Untrusted, Fleet }
pub enum Item { Turn { session: SessionId, turn: u64 }, Edit { id: String, part: u32 } }
pub enum Action {
Turn { workspace: Workspace, speaker: String },
Conclude { workspace: Workspace, peer: String, session: SessionId, text: String },
Unconclude { workspace: Workspace, text: String },
Fleet { session: SessionId, text: String },
Retract { session: SessionId, text: String },
Skip { why: String },
}
pub enum Record {
Decided { time: Timestamp, item: Item, action: Action },
Sent { time: Timestamp, item: Item, ids: Vec<String> },
Failed { time: Timestamp, item: Item, status: Option<u16>, kind: String },
}
pub struct Counts { pub decided: usize, pub sent: usize, pub waiting: usize,
pub failed: usize, pub skipped: usize }
pub enum LedgerError {
Io(PathBuf, std::io::Error),
Damaged { path: PathBuf, line: usize, why: String },
Encode(serde_json::Error),
AlreadyDecided(Item),
NotWaiting(Item),
}
pub struct Ledger { /* private */ }
impl Ledger {
pub fn open(path: &Path) -> Result<Ledger, LedgerError>;
pub fn repaired(&self) -> bool;
pub fn append(&mut self, record: Record) -> Result<(), LedgerError>;
pub fn is_decided(&self, item: &Item) -> bool;
pub fn outbox(&self) -> Vec<(Item, Action)>;
pub fn sent_ids(&self, item: &Item) -> Option<&[String]>;
pub fn conclusion(&self, workspace: Workspace, text: &str) -> Option<(Item, String)>;
pub fn counts(&self) -> Counts;
}
SessionId and Timestamp are bxcore's. The derives and serde attributes are in the skeleton;
do not change them (see "The lines").
What each public function gives
| Function | Gives |
|---|---|
open |
the ledger read through; see "Reading through" above |
repaired |
true when open dropped a cut-off last line |
append |
the record checked, written with its newline in one write, synced, then taken into the state; a refused or failed append changes nothing |
is_decided |
whether the item has its Decided record (a Skip counts) |
outbox |
every waiting item with its action, in the order of the Decided records |
sent_ids |
the ids of the item's Sent record; None when it has none (waiting, failed, skipped, unknown) |
conclusion |
the conclusion to delete for an undo: see below |
counts |
how many items are decided (every one, Skip included), sent, waiting, failed and skipped; decided is always the sum of the other four |
conclusion(workspace, text). An undo must delete the conclusion that the undone write
created. Honcho gave its id in the Sent record of the Conclude (a Conclude's Sent has
exactly one id; one with no ids is ignored). Replay the Sent records in the order they were
appended: a sent Conclude of this workspace and text puts (item, its id) on top of a stack,
and a sent Unconclude of the same workspace and text takes the top one off. The answer is the
top of the stack, or None. So it is the latest one sent that is not undone yet; another
workspace's, or one decided but not sent, is never the answer.
The errors' text (Display, given in the skeleton):
| Error | Text |
|---|---|
Io(path, e) |
<path>: <e> |
Damaged { path, line, why } |
<path>:<line>: <why>; see docs/runbook.md#honcho-ledger-damaged (the line counted from 1) |
Encode(e) |
a ledger record could not be encoded: <e> |
AlreadyDecided(item) |
turn 3 of session chat-1: decided already, or part 0 of edit 2026-09-28-0001: decided already |
NotWaiting(item) |
turn 3 of session chat-1: not waiting to be sent (the same naming) |
When a replayed line breaks a rule, why is the text of AlreadyDecided or NotWaiting; when it
does not parse, why is serde's error text.
The lines
One record per line, as serde_json::to_string writes it, then \n. Every enum except
Workspace is internally tagged: its variant is the field "type", in snake case, first; and
every one refuses unknown fields. Workspace is a plain string. A Timestamp is RFC 3339 with
milliseconds; a SessionId a string. For example (each is one line in the file):
{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"action":{"type":"turn","workspace":"trusted","speaker":"kyle"}}
{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"conclude","workspace":"trusted","peer":"kyle","session":"chat-1","text":"Kyle takes his coffee black."}}
{"type":"sent","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"ids":["msg-a1","msg-a2"]}
{"type":"failed","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-2","turn":1},"status":null,"kind":"refused"}
This is our own format, so unknown fields are errors: #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] on Item, Action and Record. serde honours
deny_unknown_fields on internally tagged enums, at every level (a given test adds a field to the
record, to its item and to its action, and expects an error each time). The skeleton has all of
this already.
Every place that changes
This is the complete list.
| Place | What happens there |
|---|---|
crates/memoryd/src/honcho/mod.rs |
pub mod ledger; (edit H1) |
crates/memoryd/src/honcho/ledger.rs |
new: a skeleton you copy, then fill 12 bodies (step 4) |
docs/runbook.md |
the entry honcho-ledger-damaged, appended (step 6) |
everything else in memoryd (config.rs, serve.rs, main.rs, honcho/config.rs, …) |
no change |
Files
(A copy command names <file>-taskNN where a later task gives a newer version of that file:
this task's version is the one named.)
- Copy, from
docs/plans/M5c1/files/(the same path under it):- new tests:
crates/memoryd/tests/honcho_ledger.rs,crates/memoryd/tests/honcho_ledger_file.rs - new test support:
crates/memoryd/tests/support/ledger.rs - the skeleton
crates/memoryd/src/honcho/ledger.rs(you fill 12 bodies in it)
- new tests:
- Append, from
docs/plans/M5c1/files/, to the end ofdocs/runbook.md, with one blank line before it:runbook-honcho-ledger-damaged.md(step 6 gives the command). - Modify:
crates/memoryd/src/honcho/mod.rs,docs/implementer-log.md. - Do not touch: any other source file, any file of another crate, any
Cargo.toml, any other test,crates/memoryd/tests/support/tmp.rs, and in the skeleton anything but the 12todo!()bodies.
Facts you need
- The state in memory. The skeleton gives it:
order(every decided item, in the order of itsDecidedrecord),entries(aHashMapfrom each item to itsEntry { action, state }),sends(every sent item, in the order of itsSentrecord), andrepaired. An entry'sStateisSkipped(decidedSkip),Waiting,Sent(ids)orFailed. Onlyapplychanges them, and only for a recordcheckaccepted. openandappendare given, as glue over your functions.openchecks every complete line before it cuts a cut-off tail, so a damaged file is never changed.appendcallscheck, encodes, callswrite_line, and only thenapply. Read both before you start.- One write per line.
write_linebuilds the line and its newline as one string and writes it with onewrite_all, thensync_data: a crash leaves either the whole line or a cut-off line without its newline, whichopendrops. Two writes, or no sync, would pass every test and still be wrong; step 7 checks for them. - A missing file creates nothing.
openonly reads.write_linecreates the file on the first append, at mode0o600, and never creates the directory: a missingmemory/is anIoerror. The file's mode is given only when the file is created (OpenOptionsExt::mode, and the process's umask can only take bits away). - Bytes, not text.
openreads the file as bytes (std::fs::read), because a cut-off last line can end inside a multi-byte character. A complete line is parsed withserde_json::from_slice, which accepts the trailing newline. - The given tests.
honcho_ledger.rs(12 tests): each kind of line exactly;appendwrites the line and a newline; unknown fields and values refused; the outbox (what is in it, and its order); a second decision refused and nothing written;SentorFailedrefused before the decision, after an outcome and for aSkip;sent_ids;conclusion(the latest not undone, only its workspace and text, only once sent); the counts add up.honcho_ledger_file.rs(12 tests): a missing and an empty file; reopening gives the same state; a cut-off last line (in the middle, one byte short, inside a character, the only line) dropped and cut; a damaged line and a line that breaks a rule, with their numbers, the text and the file unchanged; mode 0600; a missing directory; a failed write changes nothing (the test turns the ledger's path into a directory); an unreadable ledger.support/ledger.rs: record builders (decided,sent,failed,turn,edit, …) andHome, a ledger path in a directory removed on drop.
- The calls the bodies use:
Call Signature, and what it gives HashMap::contains_keyself.entries.contains_key(item) -> boolHashMap::get,get_mutself.entries.get(item) -> Option<&Entry>;get_mut(&item) -> Option<&mut Entry>HashMap::insertself.entries.insert(item, Entry { action, state })(the old value, ignored)HashMap::valuesself.entries.values(): every&EntryIterator::filter_map.iter().filter_map(|item| { ..; Some(..) }).collect()bool::then(cond).then(|| value) -> Option<T>:Some(value)whencondslice::firstids.first() -> Option<&String>Vec::push,Vec::poplive.push(x);live.pop() -> Option<T>: takes the last one offVec::as_sliceids.as_slice() -> &[String]OpenOptionsOpenOptions::new().append(true).create(true).mode(0o600).open(&path) -> io::Result<File>;.modeisstd::os::unix::fs::OpenOptionsExt(imported)Write::write_allfile.write_all(bytes: &[u8]) -> io::Result<()>(std::io::Write, imported)File::sync_datafile.sync_data() -> io::Result<()>: the data on disk, not necessarily the metadataFile::set_lenfile.set_len(size: u64) -> io::Result<()>: shrinks the file tosizebytes; the file needswrite(true)std::fs::readstd::fs::read(path) -> io::Result<Vec<u8>>io::Error::kinde.kind() == std::io::ErrorKind::NotFoundio::Error::otherstd::io::Error::other(e): wraps any error as anio::Erroru64::try_fromu64::try_from(keep: usize) -> Result<u64, TryFromIntError>Iterator::rpositionbytes.iter().rposition(|b| *b == b'\n') -> Option<usize>: the index of the last newlineserde_json::from_sliceserde_json::from_slice::<Record>(line: &[u8]) -> serde_json::Result<Record>
The edit, exactly
crates/memoryd/src/honcho/mod.rs
H1. The line
pub mod config;
becomes
pub mod config;
pub mod ledger;
Steps
-
1. Copy the given tests. From the repository root, three commands:
cp docs/plans/M5c1/files/crates/memoryd/tests/honcho_ledger.rs-task07 crates/memoryd/tests/honcho_ledger.rs cp docs/plans/M5c1/files/crates/memoryd/tests/honcho_ledger_file.rs-task07 crates/memoryd/tests/honcho_ledger_file.rs cp docs/plans/M5c1/files/crates/memoryd/tests/support/ledger.rs-task07 crates/memoryd/tests/support/ledger.rs -
2. See them fail.
cargo build --tests --keep-going -p memoryd, with a shell timeout of 600000 ms: two test targets do not compile,honcho_ledgerandhoncho_ledger_file, with 2 errors each, bothE0432(unresolved import `memoryd::honcho::ledger`): one in the test file, one insupport/ledger.rs. Every other target compiles. -
3. The module. Make edit H1, then copy the skeleton:
cp docs/plans/M5c1/files/crates/memoryd/src/honcho/ledger.rs crates/memoryd/src/honcho/cargo check -p memoryd: no error, and 20 warnings, all from the unfilled bodies: threeunused import(std::fs::OpenOptions,std::io::Write,std::os::unix::fs::OpenOptionsExt); fourteenunused variable(paththree times,item,lineandrecordtwice each,workspace,text,bytes,number,keep);fields `action` and `state` are never read;fields `path`, `order`, `entries`, and `sends` are never read; andvariants `Skipped`, `Waiting`, `Sent`, and `Failed` are never constructed. They go as you fill the bodies.Then
cargo test -p memoryd --test honcho_ledger: 2 tests pass (each_record_is_one_line_exactly_as_givenandan_unknown_field_or_value_is_refused: the types are given) and 10 fail withnot yet implemented. -
4. Fill the twelve bodies of
honcho/ledger.rs, one at a time, in the order of the file, each from its comment, and runcargo check -p memorydafter each:is_decided,outbox,sent_ids,conclusion,counts,check,apply,write_line, then the free functionsread,complete,parse,cut. When a body is filled, delete its instruction comment (the//lines inside the body) and keep the function's doc comment (///) as it is. Change nothing else in the file. After the twelfth,cargo check -p memorydprints no warning. -
5. The tests. This command, and every test passes (12 and 12):
cargo test -p memoryd --test honcho_ledger --test honcho_ledger_file -
6. The runbook. From the repository root:
{ echo; cat docs/plans/M5c1/files/runbook-honcho-ledger-damaged.md; } >> docs/runbook.mdThen
grep -c '^## honcho-ledger-damaged$' docs/runbook.mdprints1. -
7. Check. Four commands:
grep -c 'sync_data' crates/memoryd/src/honcho/ledger.rsprints2(inwrite_lineand incut), andgrep -c 'write_all' crates/memoryd/src/honcho/ledger.rsprints1.grep -rn 'runbook.md#honcho-ledger-damaged' crates/memoryd/srcprints exactly one line, theRUNBOOKconstant inhoncho/ledger.rs.grep -n 'create_dir' crates/memoryd/src/honcho/ledger.rsprints nothing: the ledger never makes its directory.git diff --stat -- crates/bxcore crates/loopd crates/brokerd crates/gatewayd crates/bxctl Cargo.toml crates/memoryd/Cargo.tomlprints nothing.
-
8. Run the gate.
cargo fmt --all, thenmake gate, with a shell timeout of 600000 ms. Expected last line:gate: ok, with 1212 tests: 1188 before this task, plus 24 new ones (12 inhoncho_ledger.rs, 12 inhoncho_ledger_file.rs). -
9. Log and commit. Add your row to
docs/implementer-log.md; its Task cell isM5c1/07-memoryd-honcho-ledger.git status --shortlistscrates/memoryd/src/honcho/mod.rs,docs/runbook.mdand your log as modified, andcrates/memoryd/src/honcho/ledger.rs, the two test files andcrates/memoryd/tests/support/ledger.rsas new. Stage withgit add crates/memoryd docs/runbook.md docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.Cargo.lockdoes not change in this task; thegit addis harmless.
Done when
make gateprintsgate: okwith 1212 tests.git diff HEAD~1 --statlists onlyhoncho/mod.rs,honcho/ledger.rs, the two new test files,tests/support/ledger.rs,docs/runbook.mdanddocs/implementer-log.md.
Stop and report if
- The line of edit H1 is not in
crates/memoryd/src/honcho/mod.rsexactly as shown, or is there more than once, orcrates/memoryd/tests/support/ledger.rsexists before step 1. - Step 2 or 3 shows a different result: other test targets, errors of another kind, other warnings, or other tests passing or failing.
cargo checkreports an error after a body you filled, and the comment does not tell you how to fix it. Quote the error, and stop.- A comment in the skeleton does not fit the code under it, or disagrees with this task's text. Quote both and stop.
- After step 4 a test fails although each body follows its comment. Do not change a test, the
support file or the skeleton's given code (
open,append, the types,Display): stop, and give the failing tests' names and their messages. - The fix seems to need a change anywhere else: another
memorydfile, another crate, a dependency, or a change to a derive or a serde attribute. It does not; stop and say why you think so. - A test outside these two files fails in the gate: check once whether it fails without your change, then stop and report both results.
- The gate passes with a count other than 1212.
What the given tests pin
Each of these changes to a finished ledger.rs makes at least one given test fail: the outbox
taking in a skipped, sent or failed item, or losing the decision order; conclusion ignoring an
Unconclude, the workspace, the order of the sends, or a Sent with no ids, or answering the
oldest; a second decision, or a Sent or Failed before the decision, after an outcome or for
a Skip, accepted; the state taken in before the write; the directory made; the mode; a cut-off
line kept, not cut, cut before the other lines are checked, or not reported by repaired; line
numbers from 0; replay without the rules; a missing file an error or an unreadable one empty; any
error text; the counts; a serde attribute removed. Two writes for one line, and a missing
sync_data, pass the tests; step 7 checks those.