M5c-1 plan: task 07, the Honcho ledger in memoryd

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 06:43:05 -07:00
co-authored by Claude Opus 5.5
parent 0e1e070fc3
commit aef6525cc7
6 changed files with 1576 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
# 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 `Decided` record says): the turn's messages to a workspace; a conclusion
to create or delete; a message, or its retraction, in the fleet workspace; or `Skip`, 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 `Decided` record. It has at most one `Sent` or `Failed`
record, and only while it is **waiting**: decided, not `Skip`, and neither sent nor failed yet.
`append` refuses a record that breaks a rule, and writes nothing for it.
- **Durable before believed.** `append` writes the line, syncs it to disk, and only then takes it
into the ledger's state in memory, as `loopd` does 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.** `open` reads 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, and `repaired()` says so. Any other line that does not parse, or
that breaks a rule when replayed in order, stops the ledger: `LedgerError::Damaged` with its
line number and the runbook pointer `honcho-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`):
```rust
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):
```text
{"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
- 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)
- Append, from `docs/plans/M5c1/files/`, to the end of `docs/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 12 `todo!()`
bodies.
## Facts you need
- **The state in memory.** The skeleton gives it: `order` (every decided item, in the order of
its `Decided` record), `entries` (a `HashMap` from each item to its `Entry { action, state }`),
`sends` (every sent item, in the order of its `Sent` record), and `repaired`. An entry's
`State` is `Skipped` (decided `Skip`), `Waiting`, `Sent(ids)` or `Failed`. Only `apply` changes
them, and only for a record `check` accepted.
- **`open` and `append` are given**, as glue over your functions. `open` checks every complete
line before it cuts a cut-off tail, so a damaged file is never changed. `append` calls `check`,
encodes, calls `write_line`, and only then `apply`. Read both before you start.
- **One write per line.** `write_line` builds the line and its newline as one string and writes
it with one `write_all`, then `sync_data`: a crash leaves either the whole line or a cut-off
line without its newline, which `open` drops. Two writes, or no sync, would pass every test and
still be wrong; step 7 checks for them.
- **A missing file creates nothing.** `open` only reads. `write_line` creates the file on the
first append, at mode `0o600`, and never creates the directory: a missing `memory/` is an
`Io` error. 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.** `open` reads 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 with
`serde_json::from_slice`, which accepts the trailing newline.
- **The given tests.**
- `honcho_ledger.rs` (12 tests): each kind of line exactly; `append` writes 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; `Sent` or `Failed` refused before the decision,
after an outcome and for a `Skip`; `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`, …) and
`Home`, a ledger path in a directory removed on drop.
- The calls the bodies use:
| Call | Signature, and what it gives |
|---|---|
| `HashMap::contains_key` | `self.entries.contains_key(item) -> bool` |
| `HashMap::get`, `get_mut` | `self.entries.get(item) -> Option<&Entry>`; `get_mut(&item) -> Option<&mut Entry>` |
| `HashMap::insert` | `self.entries.insert(item, Entry { action, state })` (the old value, ignored) |
| `HashMap::values` | `self.entries.values()`: every `&Entry` |
| `Iterator::filter_map` | `.iter().filter_map(\|item\| { ..; Some(..) }).collect()` |
| `bool::then` | `(cond).then(\|\| value) -> Option<T>`: `Some(value)` when `cond` |
| `slice::first` | `ids.first() -> Option<&String>` |
| `Vec::push`, `Vec::pop` | `live.push(x)`; `live.pop() -> Option<T>`: takes the last one off |
| `Vec::as_slice` | `ids.as_slice() -> &[String]` |
| `OpenOptions` | `OpenOptions::new().append(true).create(true).mode(0o600).open(&path) -> io::Result<File>`; `.mode` is `std::os::unix::fs::OpenOptionsExt` (imported) |
| `Write::write_all` | `file.write_all(bytes: &[u8]) -> io::Result<()>` (`std::io::Write`, imported) |
| `File::sync_data` | `file.sync_data() -> io::Result<()>`: the data on disk, not necessarily the metadata |
| `File::set_len` | `file.set_len(size: u64) -> io::Result<()>`: shrinks the file to `size` bytes; the file needs `write(true)` |
| `std::fs::read` | `std::fs::read(path) -> io::Result<Vec<u8>>` |
| `io::Error::kind` | `e.kind() == std::io::ErrorKind::NotFound` |
| `io::Error::other` | `std::io::Error::other(e)`: wraps any error as an `io::Error` |
| `u64::try_from` | `u64::try_from(keep: usize) -> Result<u64, TryFromIntError>` |
| `Iterator::rposition` | `bytes.iter().rposition(\|b\| *b == b'\n') -> Option<usize>`: the index of the last newline |
| `serde_json::from_slice` | `serde_json::from_slice::<Record>(line: &[u8]) -> serde_json::Result<Record>` |
## The edit, exactly
### `crates/memoryd/src/honcho/mod.rs`
**H1.** The line
```rust
pub mod config;
```
becomes
```rust
pub mod config;
pub mod ledger;
```
## Steps
- [ ] **1. Copy the given tests.** From the repository root, three commands:
```sh
cp docs/plans/M5c1/files/crates/memoryd/tests/honcho_ledger.rs crates/memoryd/tests/
cp docs/plans/M5c1/files/crates/memoryd/tests/honcho_ledger_file.rs crates/memoryd/tests/
cp docs/plans/M5c1/files/crates/memoryd/tests/support/ledger.rs crates/memoryd/tests/support/
```
- [ ] **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_ledger` and `honcho_ledger_file`, with 2
errors each, both `E0432` (``unresolved import `memoryd::honcho::ledger` ``): one in the test
file, one in `support/ledger.rs`. Every other target compiles.
- [ ] **3. The module.** Make edit H1, then copy the skeleton:
```sh
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: three
`unused import` (`std::fs::OpenOptions`, `std::io::Write`, `std::os::unix::fs::OpenOptionsExt`);
fourteen `unused variable` (`path` three times, `item`, `line` and `record` twice each,
`workspace`, `text`, `bytes`, `number`, `keep`); ``fields `action` and `state` are never
read``; ``fields `path`, `order`, `entries`, and `sends` are never read``; and ``variants
`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_given` and `an_unknown_field_or_value_is_refused`: the
types are given) and 10 fail with `not 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 run `cargo check -p memoryd` after each: `is_decided`,
`outbox`, `sent_ids`, `conclusion`, `counts`, `check`, `apply`, `write_line`, then the free
functions `read`, `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 memoryd` prints no warning.
- [ ] **5. The tests.** This command, and every test passes (12 and 12):
```sh
cargo test -p memoryd --test honcho_ledger --test honcho_ledger_file
```
- [ ] **6. The runbook.** From the repository root:
```sh
{ echo; cat docs/plans/M5c1/files/runbook-honcho-ledger-damaged.md; } >> docs/runbook.md
```
Then `grep -c '^## honcho-ledger-damaged$' docs/runbook.md` prints `1`.
- [ ] **7. Check.** Four commands:
- `grep -c 'sync_data' crates/memoryd/src/honcho/ledger.rs` prints `2` (in `write_line` and
in `cut`), and `grep -c 'write_all' crates/memoryd/src/honcho/ledger.rs` prints `1`.
- `grep -rn 'runbook.md#honcho-ledger-damaged' crates/memoryd/src` prints exactly one line,
the `RUNBOOK` constant in `honcho/ledger.rs`.
- `grep -n 'create_dir' crates/memoryd/src/honcho/ledger.rs` prints 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.toml` prints nothing.
- [ ] **8. Run the gate.** `cargo fmt --all`, then `make 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
in `honcho_ledger.rs`, 12 in `honcho_ledger_file.rs`).
- [ ] **9. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
`M5c1/07-memoryd-honcho-ledger`. `git status --short` lists `crates/memoryd/src/honcho/mod.rs`,
`docs/runbook.md` and your log as modified, and `crates/memoryd/src/honcho/ledger.rs`, the two
test files and `crates/memoryd/tests/support/ledger.rs` as new. Stage with
`git add crates/memoryd docs/runbook.md docs/implementer-log.md Cargo.lock`, then commit as
`AGENTS.md` says. `Cargo.lock` does not change in this task; the `git add` is harmless.
## Done when
- `make gate` prints `gate: ok` with 1212 tests.
- `git diff HEAD~1 --stat` lists only `honcho/mod.rs`, `honcho/ledger.rs`, the two new test
files, `tests/support/ledger.rs`, `docs/runbook.md` and `docs/implementer-log.md`.
## Stop and report if
- The line of edit H1 is not in `crates/memoryd/src/honcho/mod.rs` exactly as shown, or is there
more than once, or `crates/memoryd/tests/support/ledger.rs` exists 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 check` reports 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 `memoryd` file, 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.
@@ -0,0 +1,318 @@
//! The Honcho ledger, `memory/honcho.jsonl`: one JSON line per event, appended and never rewritten
//! (M5c spec, section 5). A turn or a memory write is `decided` once, then `sent` or `failed` once.
//! What is decided and neither sent nor failed is the outbox; there is no other queue. `memoryd`
//! reads the ledger through at start, so nothing is decided or sent twice.
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use bxcore::{SessionId, Timestamp};
use serde::{Deserialize, Serialize};
/// The runbook entry for a ledger that does not read through.
pub const RUNBOOK: &str = "see docs/runbook.md#honcho-ledger-damaged";
/// One of this agent's three workspaces.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Workspace {
Trusted,
Untrusted,
Fleet,
}
/// What is sent: a finished turn of a session, or one part of a journalled memory edit.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Item {
Turn { session: SessionId, turn: u64 },
Edit { id: String, part: u32 },
}
/// Where an item goes, decided once. A turn's text is read from the session log when it is sent,
/// never stored here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Action {
/// The turn's two messages, the speaker's and the agent's reply.
Turn {
workspace: Workspace,
speaker: String,
},
/// A conclusion about `peer`, stamped with the Honcho session of `session`.
Conclude {
workspace: Workspace,
peer: String,
session: SessionId,
text: String,
},
/// Delete the conclusion that a sent `Conclude` with this text created.
Unconclude { workspace: Workspace, text: String },
/// A message from the agent's peer in the fleet workspace.
Fleet { session: SessionId, text: String },
/// `retracted: <text>`, from the same peer in the same workspace.
Retract { session: SessionId, text: String },
/// Nothing is sent, and why.
Skip { why: String },
}
/// One line of the ledger.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Record {
Decided {
time: Timestamp,
item: Item,
action: Action,
},
/// Honcho's ids for what the send created: a `Conclude` has exactly one.
Sent {
time: Timestamp,
item: Item,
ids: Vec<String>,
},
/// Honcho refused the send: its status, if it answered, and the kind of error.
Failed {
time: Timestamp,
item: Item,
status: Option<u16>,
kind: String,
},
}
/// How many items are in each state. `decided` counts every item, `Skip` included, so it is
/// always `sent + waiting + failed + skipped`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Counts {
pub decided: usize,
pub sent: usize,
pub waiting: usize,
pub failed: usize,
pub skipped: usize,
}
#[derive(Debug)]
pub enum LedgerError {
Io(PathBuf, std::io::Error),
/// A complete line (numbered from 1) that does not parse, or breaks the ledger's rules.
Damaged {
path: PathBuf,
line: usize,
why: String,
},
Encode(serde_json::Error),
AlreadyDecided(Item),
NotWaiting(Item),
}
impl std::fmt::Display for LedgerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LedgerError::Io(path, err) => write!(f, "{}: {err}", path.display()),
LedgerError::Damaged { path, line, why } => {
write!(f, "{}:{line}: {why}; {RUNBOOK}", path.display())
}
LedgerError::Encode(err) => write!(f, "a ledger record could not be encoded: {err}"),
LedgerError::AlreadyDecided(item) => write!(f, "{}: decided already", describe(item)),
LedgerError::NotWaiting(item) => {
write!(f, "{}: not waiting to be sent", describe(item))
}
}
}
}
impl std::error::Error for LedgerError {}
/// Where a decided item stands.
#[derive(Debug, Clone, PartialEq, Eq)]
enum State {
Skipped,
Waiting,
Sent(Vec<String>),
Failed,
}
#[derive(Debug, Clone)]
struct Entry {
action: Action,
state: State,
}
/// The ledger, read through: the file's path and the state its records add up to.
#[derive(Debug)]
pub struct Ledger {
path: PathBuf,
/// Every decided item, in the order of its `Decided` record.
order: Vec<Item>,
entries: HashMap<Item, Entry>,
/// Every sent item, in the order of its `Sent` record.
sends: Vec<Item>,
repaired: bool,
}
impl Ledger {
/// Read the ledger at `path` through. A missing file is an empty ledger, and nothing is
/// created. A last line without its newline, cut off by a crash, is dropped and cut from the
/// file; any other line that does not read is `Damaged`, and the file is left as it is.
pub fn open(path: &Path) -> Result<Ledger, LedgerError> {
let mut ledger = Ledger {
path: path.to_path_buf(),
order: Vec::new(),
entries: HashMap::new(),
sends: Vec::new(),
repaired: false,
};
let Some(bytes) = read(path)? else {
return Ok(ledger);
};
let whole = complete(&bytes);
let lines = bytes.get(..whole).unwrap_or_default();
for (index, line) in lines.split_inclusive(|b| *b == b'\n').enumerate() {
let record = parse(path, index + 1, line)?;
if let Err(e) = ledger.check(&record) {
return Err(LedgerError::Damaged {
path: path.to_path_buf(),
line: index + 1,
why: e.to_string(),
});
}
ledger.apply(record);
}
if whole < bytes.len() {
cut(path, whole)?;
ledger.repaired = true;
}
Ok(ledger)
}
/// Whether `open` dropped a cut-off last line.
pub fn repaired(&self) -> bool {
self.repaired
}
/// Append `record` to the file and sync it, and only then take it into the state. A record
/// the rules refuse, or a failed write, changes nothing in memory.
pub fn append(&mut self, record: Record) -> Result<(), LedgerError> {
self.check(&record)?;
let line = serde_json::to_string(&record).map_err(LedgerError::Encode)?;
self.write_line(&line)?;
self.apply(record);
Ok(())
}
/// Whether `item` has its `Decided` record.
pub fn is_decided(&self, item: &Item) -> bool {
// `self.entries.contains_key(item)`.
todo!()
}
/// The items waiting to be sent, with their actions, in the order they were decided.
pub fn outbox(&self) -> Vec<(Item, Action)> {
// Walk `self.order`, in order, with `.iter().filter_map(..)`: for each item,
// `let entry = self.entries.get(item)?;`, then keep
// `(item.clone(), entry.action.clone())` only when `entry.state == State::Waiting`:
// `(entry.state == State::Waiting).then(|| ..)`. `.collect()` the result.
todo!()
}
/// The ids of `item`'s `Sent` record, if it has one.
pub fn sent_ids(&self, item: &Item) -> Option<&[String]> {
// `match &self.entries.get(item)?.state`: `State::Sent(ids)` -> `Some(ids.as_slice())`; any
// other state -> `None`.
todo!()
}
/// The latest sent `Conclude` of `text` in `workspace` that no sent `Unconclude` has undone:
/// its item and the conclusion's id.
pub fn conclusion(&self, workspace: Workspace, text: &str) -> Option<(Item, String)> {
// `let mut live: Vec<(Item, String)> = Vec::new();`. For each `item` in `&self.sends`
// (the order of the `Sent` records):
// `let Some(entry) = self.entries.get(item) else { continue; };` and
// `let State::Sent(ids) = &entry.state else { continue; };`. Then `match &entry.action`:
// `Action::Conclude { workspace: w, text: t, .. } if *w == workspace && t == text` pushes
// `(item.clone(), id.clone())` when `ids.first()` is `Some(id)` (no ids: nothing);
// `Action::Unconclude { workspace: w, text: t }` with the same guard does `live.pop();`;
// `_ => {}`. At the end, `live.pop()`.
todo!()
}
/// How many items are in each state.
pub fn counts(&self) -> Counts {
// Start from `Counts::default()`. For each entry of `self.entries.values()`, add 1 to
// `decided`, and 1 to the field of its state: `Skipped` -> `skipped`, `Waiting` ->
// `waiting`, `Sent(_)` -> `sent`, `Failed` -> `failed`. Return the counts.
todo!()
}
/// The ledger's rules: an item is decided once; it is sent or failed once, and only while it
/// is waiting.
fn check(&self, record: &Record) -> Result<(), LedgerError> {
// `match record`. `Record::Decided { item, .. }`: if `self.entries.contains_key(item)`,
// `Err(LedgerError::AlreadyDecided(item.clone()))`, else `Ok(())`.
// `Record::Sent { item, .. } | Record::Failed { item, .. }`: a block `{ .. }` holding
// `match self.entries.get(item)`: `Some(entry) if entry.state == State::Waiting => Ok(())`,
// and otherwise (no entry, or another state) `Err(LedgerError::NotWaiting(item.clone()))`.
todo!()
}
/// Take a checked record into the state.
fn apply(&mut self, record: Record) {
// `match record`. `Record::Decided { item, action, .. }`: `let state = match action {
// Action::Skip { .. } => State::Skipped, _ => State::Waiting };`, push `item.clone()`
// onto `self.order`, then `self.entries.insert(item, Entry { action, state })`.
// `Record::Sent { item, ids, .. }`: `if let Some(entry) = self.entries.get_mut(&item)`,
// set `entry.state = State::Sent(ids)`; then push `item` onto `self.sends`.
// `Record::Failed { item, .. }`: the same `if let`, setting `entry.state = State::Failed`.
todo!()
}
/// Append `line` and a newline to the file in one write, then sync the data.
fn write_line(&self, line: &str) -> Result<(), LedgerError> {
// `let io = |e| LedgerError::Io(self.path.clone(), e);`. Open `self.path` with
// `OpenOptions::new().append(true).create(true).mode(0o600)`, then ONE
// `write_all(format!("{line}\n").as_bytes())`, then `sync_data()`. Each error goes through
// `.map_err(io)`; the last line is `file.sync_data().map_err(io)`.
todo!()
}
}
/// The file's bytes, or `None` when there is no file.
fn read(path: &Path) -> Result<Option<Vec<u8>>, LedgerError> {
// `match std::fs::read(path)`: `Ok(bytes)` -> `Ok(Some(bytes))`; an error `e` whose `e.kind()`
// is `std::io::ErrorKind::NotFound` -> `Ok(None)`; any other error `e` ->
// `Err(LedgerError::Io(path.to_path_buf(), e))`.
todo!()
}
/// How many bytes the complete lines take: up to and including the last newline.
fn complete(bytes: &[u8]) -> usize {
// `match bytes.iter().rposition(|b| *b == b'\n')`: `Some(last)` -> `last + 1`; `None` -> `0`.
todo!()
}
/// Line `number` (from 1), with its newline, as a record.
fn parse(path: &Path, number: usize, line: &[u8]) -> Result<Record, LedgerError> {
// `serde_json::from_slice(line)`, its error `e` mapped to `LedgerError::Damaged` with `path:
// path.to_path_buf()`, `line: number` and `why: e.to_string()`.
todo!()
}
/// Cut the file to its first `keep` bytes, and sync it.
fn cut(path: &Path, keep: usize) -> Result<(), LedgerError> {
// `let io = |e| LedgerError::Io(path.to_path_buf(), e);` and `let len =
// u64::try_from(keep).map_err(|e| io(std::io::Error::other(e)))?;`. Open `path` with
// `OpenOptions::new().write(true)` (no `create`), then `set_len(len)`, then `sync_data()`. Each
// error goes through `.map_err(io)`; the last line is `file.sync_data().map_err(io)`.
todo!()
}
/// An item as the error messages name it.
fn describe(item: &Item) -> String {
match item {
Item::Turn { session, turn } => format!("turn {turn} of session {}", session.as_str()),
Item::Edit { id, part } => format!("part {part} of edit {id}"),
}
}
@@ -0,0 +1,451 @@
//! The Honcho ledger, `memory/honcho.jsonl` (M5c spec, section 5): its lines, exactly, and the
//! rules of `append`, the outbox, the sent ids, the live conclusions and the counts. Reading the
//! file back is in `honcho_ledger_file.rs`. Do not edit.
#[path = "support/ledger.rs"]
mod ledger;
#[path = "support/tmp.rs"]
mod tmp;
use ledger::{
Home, conclude, decided, edit, failed, sent, sid, skip, to_trusted, turn, unconclude,
};
use memoryd::honcho::ledger::{Action, Counts, Item, LedgerError, Record, Workspace};
/// One record of each kind, and of each action, with its line exactly.
fn examples() -> Vec<(Record, &'static str)> {
vec![
(
decided(&turn("chat-1", 3), to_trusted("kyle")),
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"action":{"type":"turn","workspace":"trusted","speaker":"kyle"}}"#,
),
(
decided(
&turn("chat-2", 1),
Action::Turn {
workspace: Workspace::Untrusted,
speaker: "ana_b".to_string(),
},
),
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-2","turn":1},"action":{"type":"turn","workspace":"untrusted","speaker":"ana_b"}}"#,
),
(
decided(
&edit("2026-09-28-0001", 0),
conclude(Workspace::Trusted, "Kyle takes his coffee black."),
),
r#"{"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."}}"#,
),
(
decided(
&edit("2026-09-28-0002", 1),
unconclude(Workspace::Trusted, "Kyle takes his coffee black."),
),
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0002","part":1},"action":{"type":"unconclude","workspace":"trusted","text":"Kyle takes his coffee black."}}"#,
),
(
decided(
&edit("2026-09-28-0003", 0),
Action::Fleet {
session: sid("chat-2"),
text: "The build host is \"straylight\".\nIt runs NixOS.".to_string(),
},
),
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0003","part":0},"action":{"type":"fleet","session":"chat-2","text":"The build host is \"straylight\".\nIt runs NixOS."}}"#,
),
(
decided(
&edit("2026-09-28-0004", 0),
Action::Retract {
session: sid("chat-2"),
text: "The build host is titan.".to_string(),
},
),
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0004","part":0},"action":{"type":"retract","session":"chat-2","text":"The build host is titan."}}"#,
),
(
decided(&turn("chat-3", 7), skip("the session is secret")),
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-3","turn":7},"action":{"type":"skip","why":"the session is secret"}}"#,
),
(
sent(&turn("chat-1", 3), &["msg-a1", "msg-a2"]),
r#"{"type":"sent","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"ids":["msg-a1","msg-a2"]}"#,
),
(
failed(&edit("2026-09-28-0001", 0), Some(422)),
r#"{"type":"failed","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"status":422,"kind":"refused"}"#,
),
(
failed(&turn("chat-2", 1), None),
r#"{"type":"failed","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-2","turn":1},"status":null,"kind":"refused"}"#,
),
]
}
#[test]
fn each_record_is_one_line_exactly_as_given() {
for (record, text) in examples() {
assert_eq!(serde_json::to_string(&record).unwrap(), text);
assert_eq!(
serde_json::from_str::<Record>(text).unwrap(),
record,
"{text}"
);
}
for (workspace, text) in [
(Workspace::Trusted, "\"trusted\""),
(Workspace::Untrusted, "\"untrusted\""),
(Workspace::Fleet, "\"fleet\""),
] {
assert_eq!(serde_json::to_string(&workspace).unwrap(), text);
assert_eq!(serde_json::from_str::<Workspace>(text).unwrap(), workspace);
}
}
#[test]
fn append_writes_the_line_and_a_newline() {
let home = Home::new("ledger-append");
let mut ledger = home.open();
let (first, second) = (examples().remove(0), examples().remove(7));
ledger.append(first.0).unwrap();
assert_eq!(home.text(), format!("{}\n", first.1));
ledger.append(second.0).unwrap();
assert_eq!(home.text(), format!("{}\n{}\n", first.1, second.1));
}
#[test]
fn an_unknown_field_or_value_is_refused() {
for (_, text) in examples() {
let mut broken = vec![format!("{},\"extra\":1}}", &text[..text.len() - 1])];
broken.push(text.replacen("\"item\":{", "\"item\":{\"extra\":1,", 1));
if text.contains("\"action\":{") {
broken.push(text.replacen("\"action\":{", "\"action\":{\"extra\":1,", 1));
}
for line in broken {
let err = serde_json::from_str::<Record>(&line).unwrap_err();
assert!(
err.to_string().contains("unknown field `extra`"),
"{line}: {err}"
);
}
}
let (_, text) = examples().remove(0);
for (from, to) in [
("\"trusted\"", "\"shared\""),
("\"type\":\"decided\"", "\"type\":\"queued\""),
(
"\"type\":\"turn\",\"session\"",
"\"type\":\"call\",\"session\"",
),
("\"session\":\"chat-1\"", "\"session\":\"Chat 1\""),
(
"\"time\":\"2026-09-28T10:00:00.000Z\"",
"\"time\":\"today\"",
),
] {
assert_eq!(text.matches(from).count(), 1, "{from}");
let line = text.replace(from, to);
assert!(serde_json::from_str::<Record>(&line).is_err(), "{line}");
}
}
#[test]
fn the_outbox_is_what_is_decided_and_not_skipped_sent_or_failed() {
let home = Home::new("ledger-outbox");
let mut ledger = home.open();
let (a, b, c, d, e) = (
turn("chat-1", 1),
turn("chat-1", 2),
edit("2026-09-28-0001", 0),
edit("2026-09-28-0002", 0),
turn("chat-1", 3),
);
ledger.append(decided(&a, to_trusted("kyle"))).unwrap();
ledger.append(decided(&b, skip("no speaker"))).unwrap();
ledger
.append(decided(&c, conclude(Workspace::Trusted, "one")))
.unwrap();
ledger
.append(decided(&d, conclude(Workspace::Trusted, "two")))
.unwrap();
ledger.append(decided(&e, to_trusted("ana_b"))).unwrap();
ledger.append(sent(&c, &["c-1"])).unwrap();
ledger.append(failed(&d, Some(400))).unwrap();
assert_eq!(
ledger.outbox(),
vec![
(a.clone(), to_trusted("kyle")),
(e.clone(), to_trusted("ana_b"))
]
);
for item in [&a, &b, &c, &d, &e] {
assert!(ledger.is_decided(item), "{item:?}");
}
assert!(!ledger.is_decided(&turn("chat-1", 4)));
assert!(!ledger.is_decided(&edit("2026-09-28-0001", 1)));
}
#[test]
fn the_outbox_keeps_the_order_of_decision() {
let home = Home::new("ledger-order");
let mut ledger = home.open();
let items = [
turn("chat-b", 2),
turn("chat-a", 9),
edit("2026-09-28-0002", 0),
turn("chat-b", 1),
edit("2026-09-28-0001", 3),
edit("2026-09-28-0001", 0),
];
for item in &items {
ledger.append(decided(item, skip("placeholder"))).unwrap();
}
assert!(ledger.outbox().is_empty());
let home = Home::new("ledger-order-2");
let mut ledger = home.open();
for item in &items {
ledger.append(decided(item, to_trusted("kyle"))).unwrap();
}
let order: Vec<Item> = ledger.outbox().into_iter().map(|(item, _)| item).collect();
assert_eq!(order, items.to_vec());
ledger.append(sent(&items[1], &["m"])).unwrap();
let order: Vec<Item> = ledger.outbox().into_iter().map(|(item, _)| item).collect();
let mut left = items.to_vec();
left.remove(1);
assert_eq!(order, left);
}
#[test]
fn a_second_decision_is_refused_and_writes_nothing() {
let home = Home::new("ledger-twice");
let mut ledger = home.open();
let item = turn("chat-1", 1);
ledger.append(decided(&item, to_trusted("kyle"))).unwrap();
let before = home.text();
for action in [to_trusted("kyle"), skip("again")] {
let err = ledger.append(decided(&item, action)).unwrap_err();
assert!(
matches!(&err, LedgerError::AlreadyDecided(i) if *i == item),
"{err:?}"
);
assert_eq!(err.to_string(), "turn 1 of session chat-1: decided already");
}
assert_eq!(home.text(), before);
assert_eq!(ledger.outbox(), vec![(item, to_trusted("kyle"))]);
assert_eq!(ledger.counts().decided, 1);
}
#[test]
fn sent_or_failed_before_the_decision_is_refused() {
let home = Home::new("ledger-undecided");
let mut ledger = home.open();
let item = edit("2026-09-28-0001", 2);
for record in [sent(&item, &["x"]), failed(&item, Some(404))] {
let err = ledger.append(record).unwrap_err();
assert!(
matches!(&err, LedgerError::NotWaiting(i) if *i == item),
"{err:?}"
);
assert_eq!(
err.to_string(),
"part 2 of edit 2026-09-28-0001: not waiting to be sent"
);
}
assert!(!home.path.exists(), "a refused append wrote the file");
assert_eq!(ledger.counts(), Counts::default());
assert_eq!(ledger.sent_ids(&item), None);
}
#[test]
fn an_item_is_sent_or_failed_once_and_never_when_skipped() {
let home = Home::new("ledger-once");
let mut ledger = home.open();
let (s, f, k) = (turn("chat-1", 1), turn("chat-1", 2), turn("chat-1", 3));
ledger.append(decided(&s, to_trusted("kyle"))).unwrap();
ledger.append(decided(&f, to_trusted("kyle"))).unwrap();
ledger.append(decided(&k, skip("secret"))).unwrap();
ledger.append(sent(&s, &["m1", "m2"])).unwrap();
ledger.append(failed(&f, None)).unwrap();
let before = home.text();
for record in [
sent(&s, &["m3"]),
failed(&s, Some(500)),
sent(&f, &["m4"]),
failed(&f, Some(400)),
sent(&k, &["m5"]),
failed(&k, None),
] {
let err = ledger.append(record.clone()).unwrap_err();
assert!(
matches!(err, LedgerError::NotWaiting(_)),
"{record:?}: {err:?}"
);
}
assert_eq!(home.text(), before);
assert_eq!(
ledger.sent_ids(&s),
Some(&["m1".to_string(), "m2".to_string()][..])
);
}
#[test]
fn sent_ids_are_those_of_the_sent_record() {
let home = Home::new("ledger-ids");
let mut ledger = home.open();
let (waiting, done, refused, skipped) = (
turn("chat-1", 1),
turn("chat-1", 2),
turn("chat-1", 3),
turn("chat-1", 4),
);
for item in [&waiting, &done, &refused] {
ledger.append(decided(item, to_trusted("kyle"))).unwrap();
}
ledger
.append(decided(&skipped, skip("no speaker")))
.unwrap();
ledger.append(sent(&done, &["m-1", "m-2"])).unwrap();
ledger.append(failed(&refused, Some(413))).unwrap();
assert_eq!(
ledger.sent_ids(&done),
Some(&["m-1".to_string(), "m-2".to_string()][..])
);
for item in [&waiting, &refused, &skipped, &turn("chat-9", 1)] {
assert_eq!(ledger.sent_ids(item), None, "{item:?}");
}
}
#[test]
fn a_conclusion_is_the_latest_one_not_undone() {
let home = Home::new("ledger-latest");
let mut ledger = home.open();
let text = "Kyle lives in Oakland.";
let (first, second, undo1, undo2) = (
edit("2026-09-28-0001", 0),
edit("2026-09-28-0002", 0),
edit("2026-09-28-0003", 0),
edit("2026-09-28-0004", 0),
);
ledger
.append(decided(&first, conclude(Workspace::Trusted, text)))
.unwrap();
assert_eq!(ledger.conclusion(Workspace::Trusted, text), None);
ledger.append(sent(&first, &["c-1"])).unwrap();
let found = |id: &str, item: &Item| Some((item.clone(), id.to_string()));
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
found("c-1", &first)
);
ledger
.append(decided(&second, conclude(Workspace::Trusted, text)))
.unwrap();
ledger.append(sent(&second, &["c-2"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
found("c-2", &second)
);
ledger
.append(decided(&undo1, unconclude(Workspace::Trusted, text)))
.unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
found("c-2", &second)
);
ledger.append(sent(&undo1, &[])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
found("c-1", &first)
);
ledger
.append(decided(&undo2, unconclude(Workspace::Trusted, text)))
.unwrap();
ledger.append(sent(&undo2, &[])).unwrap();
assert_eq!(ledger.conclusion(Workspace::Trusted, text), None);
// The latest is the one sent last, not the one decided last.
let (early, late) = (edit("2026-09-28-0005", 0), edit("2026-09-28-0006", 0));
for item in [&early, &late] {
ledger
.append(decided(item, conclude(Workspace::Trusted, "y")))
.unwrap();
}
ledger.append(sent(&late, &["c-late"])).unwrap();
ledger.append(sent(&early, &["c-early"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, "y"),
found("c-early", &early)
);
}
#[test]
fn a_conclusion_is_found_only_by_its_workspace_and_text_once_sent() {
let home = Home::new("ledger-where");
let mut ledger = home.open();
let (sent_one, unsent, empty, other, undo_elsewhere, undo_unsent) = (
edit("2026-09-28-0001", 0),
edit("2026-09-28-0002", 0),
edit("2026-09-28-0003", 0),
edit("2026-09-28-0004", 0),
edit("2026-09-28-0005", 0),
edit("2026-09-28-0006", 0),
);
let actions = [
(&sent_one, conclude(Workspace::Trusted, "a")),
(&unsent, conclude(Workspace::Trusted, "b")),
(&empty, conclude(Workspace::Trusted, "c")),
(&other, conclude(Workspace::Untrusted, "a")),
(&undo_elsewhere, unconclude(Workspace::Untrusted, "a")),
(&undo_unsent, unconclude(Workspace::Trusted, "a")),
];
for (item, action) in actions {
ledger.append(decided(item, action)).unwrap();
}
ledger.append(sent(&sent_one, &["c-a"])).unwrap();
ledger.append(sent(&empty, &[])).unwrap();
ledger.append(sent(&other, &["c-other"])).unwrap();
ledger.append(sent(&undo_elsewhere, &[])).unwrap();
let a = Some((sent_one.clone(), "c-a".to_string()));
assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), a);
assert_eq!(ledger.conclusion(Workspace::Trusted, "b"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "c"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "A"), None);
assert_eq!(ledger.conclusion(Workspace::Untrusted, "a"), None);
assert_eq!(ledger.conclusion(Workspace::Fleet, "a"), None);
ledger.append(sent(&undo_unsent, &[])).unwrap();
assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), None);
}
#[test]
fn the_counts_add_up() {
let home = Home::new("ledger-counts");
let mut ledger = home.open();
assert_eq!(ledger.counts(), Counts::default());
let items: Vec<Item> = (1..=9).map(|n| turn("chat-1", n)).collect();
for (n, item) in items.iter().enumerate() {
let action = if n < 2 {
skip("secret")
} else {
to_trusted("kyle")
};
ledger.append(decided(item, action)).unwrap();
}
for item in &items[2..5] {
ledger.append(sent(item, &["m"])).unwrap();
}
ledger.append(failed(&items[5], Some(400))).unwrap();
let counts = ledger.counts();
assert_eq!(
counts,
Counts {
decided: 9,
sent: 3,
waiting: 3,
failed: 1,
skipped: 2,
}
);
assert_eq!(
counts.decided,
counts.sent + counts.waiting + counts.failed + counts.skipped
);
assert_eq!(ledger.outbox().len(), counts.waiting);
}
@@ -0,0 +1,303 @@
//! The Honcho ledger's file (M5c spec, section 5): read through at start, a line cut off by a
//! crash dropped and cut from the file, a damaged line refused with its number and the runbook
//! pointer, the file's mode, and a failed write that changes nothing. Do not edit.
#[path = "support/ledger.rs"]
mod ledger;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use ledger::{
Home, conclude, decided, edit, failed, line, sent, skip, to_trusted, turn, unconclude,
};
use memoryd::honcho::ledger::{Counts, Item, Ledger, LedgerError, Workspace};
const POINTER: &str = "; see docs/runbook.md#honcho-ledger-damaged";
/// `open`'s error for the file of `home`, which must be `Damaged`: its line and its display.
fn damaged(home: &Home) -> (usize, String) {
let err = Ledger::open(&home.path).unwrap_err();
let shown = err.to_string();
match err {
LedgerError::Damaged { path, line, .. } => {
assert_eq!(path, home.path);
(line, shown)
}
other => panic!("not Damaged: {other:?}"),
}
}
#[test]
fn a_missing_file_is_an_empty_ledger_and_nothing_is_created() {
let home = Home::new("ledger-missing");
let ledger = home.open();
assert_eq!(ledger.counts(), Counts::default());
assert!(ledger.outbox().is_empty());
assert!(!ledger.repaired());
assert!(!home.path.exists());
let nowhere = home.dir.path().join("memory").join("honcho.jsonl");
let ledger = Ledger::open(&nowhere).unwrap();
assert_eq!(ledger.counts(), Counts::default());
assert!(!nowhere.parent().unwrap().exists());
}
#[test]
fn an_empty_file_is_an_empty_ledger() {
let home = Home::new("ledger-empty");
home.write("");
let ledger = home.open();
assert_eq!(ledger.counts(), Counts::default());
assert!(!ledger.repaired());
assert_eq!(home.text(), "");
}
#[test]
fn reopening_reads_the_ledger_through() {
let home = Home::new("ledger-reopen");
let mut ledger = home.open();
let (a, b, c, d, e) = (
turn("chat-1", 1),
turn("chat-1", 2),
edit("2026-09-28-0001", 0),
edit("2026-09-28-0002", 0),
turn("chat-2", 1),
);
ledger.append(decided(&b, to_trusted("kyle"))).unwrap();
ledger.append(decided(&a, skip("no speaker"))).unwrap();
ledger
.append(decided(&c, conclude(Workspace::Trusted, "x")))
.unwrap();
ledger
.append(decided(&d, unconclude(Workspace::Trusted, "x")))
.unwrap();
ledger.append(decided(&e, to_trusted("ana_b"))).unwrap();
ledger.append(sent(&c, &["c-1"])).unwrap();
ledger.append(failed(&d, Some(404))).unwrap();
let again = home.open();
assert!(!again.repaired());
assert_eq!(again.outbox(), ledger.outbox());
assert_eq!(again.counts(), ledger.counts());
assert_eq!(again.sent_ids(&c), Some(&["c-1".to_string()][..]));
assert_eq!(
again.conclusion(Workspace::Trusted, "x"),
Some((c.clone(), "c-1".to_string()))
);
for item in [&a, &b, &c, &d, &e] {
assert!(again.is_decided(item), "{item:?}");
}
let mut again = again;
let err = again.append(decided(&b, skip("again"))).unwrap_err();
assert!(matches!(err, LedgerError::AlreadyDecided(_)), "{err:?}");
let err = again.append(sent(&c, &["c-2"])).unwrap_err();
assert!(matches!(err, LedgerError::NotWaiting(_)), "{err:?}");
again.append(sent(&b, &["m-1", "m-2"])).unwrap();
let third = home.open();
assert_eq!(third.outbox(), vec![(e, to_trusted("ana_b"))]);
assert_eq!(
third.counts(),
Counts {
decided: 5,
sent: 2,
waiting: 1,
failed: 1,
skipped: 1,
}
);
}
#[test]
fn a_cut_off_last_line_is_dropped_and_cut_from_the_file() {
let (a, b, c) = (turn("chat-1", 1), turn("chat-1", 2), turn("chat-1", 3));
let whole = format!(
"{}{}",
line(&decided(&a, to_trusted("kyle"))),
line(&decided(&b, to_trusted("kyle")))
);
let third = line(&sent(&a, &["m-1"]));
// Cut in the middle, and one byte short of the newline: a complete record without its
// newline is still a cut-off line.
for cut in [third.len() / 2, third.len() - 1] {
let home = Home::new("ledger-torn");
home.write(&format!("{whole}{}", &third[..cut]));
let mut ledger = home.open();
assert!(ledger.repaired());
assert_eq!(home.text(), whole);
assert_eq!(ledger.outbox().len(), 2, "the cut-off send counted");
assert_eq!(ledger.sent_ids(&a), None);
ledger.append(decided(&c, to_trusted("kyle"))).unwrap();
ledger.append(sent(&a, &["m-2"])).unwrap();
let again = home.open();
assert!(!again.repaired());
assert_eq!(again.counts().decided, 3);
assert_eq!(again.sent_ids(&a), Some(&["m-2".to_string()][..]));
}
}
#[test]
fn a_cut_off_first_line_leaves_an_empty_file() {
let home = Home::new("ledger-torn-first");
let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle")));
home.write(&first[..10]);
let ledger = home.open();
assert!(ledger.repaired());
assert_eq!(ledger.counts(), Counts::default());
assert_eq!(std::fs::metadata(&home.path).unwrap().len(), 0);
}
#[test]
fn a_line_cut_inside_a_character_is_dropped_too() {
let home = Home::new("ledger-torn-utf8");
let a = edit("2026-09-28-0001", 0);
let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle")));
let second = line(&decided(&a, conclude(Workspace::Trusted, "Kyle's café")));
let at = second.find('é').unwrap() + 1;
let mut bytes = first.clone().into_bytes();
bytes.extend_from_slice(&second.as_bytes()[..at]);
std::fs::write(&home.path, &bytes).unwrap();
let ledger = home.open();
assert!(ledger.repaired());
assert_eq!(home.text(), first);
assert!(!ledger.is_decided(&a));
}
#[test]
fn a_damaged_line_stops_the_ledger_with_its_number_and_the_pointer() {
let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle")));
let third = line(&decided(&turn("chat-1", 2), to_trusted("kyle")));
let extra = first.replacen("\"speaker\"", "\"extra\":1,\"speaker\"", 1);
for (second, why) in [
("{\"type\":\"decided\"\n".to_string(), "EOF while parsing"),
("\n".to_string(), "EOF while parsing"),
("not json\n".to_string(), "expected"),
(extra, "unknown field `extra`"),
] {
for tail in ["", "{\"type\":\"se"] {
let home = Home::new("ledger-damaged");
let text = format!("{first}{second}{third}{tail}");
home.write(&text);
let (number, shown) = damaged(&home);
assert_eq!(number, 2, "{second}");
let start = format!("{}:2: ", home.path.display());
assert!(shown.starts_with(&start), "{shown}");
assert!(shown.contains(why), "{shown}");
assert!(shown.ends_with(POINTER), "{shown}");
assert_eq!(home.text(), text, "a damaged ledger was changed");
}
}
}
#[test]
fn a_line_that_breaks_the_rules_is_damage() {
let (a, b) = (turn("chat-1", 1), edit("2026-09-28-0001", 2));
let cases = [
(
vec![sent(&a, &["m"])],
1,
"turn 1 of session chat-1: not waiting to be sent",
),
(
vec![decided(&a, to_trusted("kyle")), decided(&a, skip("again"))],
2,
"turn 1 of session chat-1: decided already",
),
(
vec![decided(&b, skip("flagged")), failed(&b, Some(400))],
2,
"part 2 of edit 2026-09-28-0001: not waiting to be sent",
),
(
vec![
decided(&a, to_trusted("kyle")),
sent(&a, &["m"]),
decided(&b, to_trusted("kyle")),
failed(&a, None),
],
4,
"turn 1 of session chat-1: not waiting to be sent",
),
];
for (records, number, why) in cases {
let home = Home::new("ledger-rules");
let text: String = records.iter().map(line).collect();
home.write(&text);
let (got, shown) = damaged(&home);
assert_eq!(got, number, "{text}");
let expected = format!("{}:{number}: {why}{POINTER}", home.path.display());
assert_eq!(shown, expected);
assert_eq!(home.text(), text);
}
}
#[test]
fn the_file_is_made_0600() {
let home = Home::new("ledger-mode");
let mut ledger = home.open();
ledger
.append(decided(&turn("chat-1", 1), to_trusted("kyle")))
.unwrap();
let mode = std::fs::metadata(&home.path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "{mode:o}");
}
#[test]
fn a_missing_directory_is_an_io_error_and_changes_nothing() {
let home = Home::new("ledger-nodir");
let path = home.dir.path().join("memory").join("honcho.jsonl");
let mut ledger = Ledger::open(&path).unwrap();
let item = turn("chat-1", 1);
let err = ledger
.append(decided(&item, to_trusted("kyle")))
.unwrap_err();
assert!(
matches!(&err, LedgerError::Io(p, _) if *p == path),
"{err:?}"
);
assert!(
err.to_string()
.starts_with(&format!("{}: ", path.display()))
);
assert!(!ledger.is_decided(&item));
assert_eq!(ledger.counts(), Counts::default());
assert!(!path.parent().unwrap().exists(), "the directory was made");
}
#[test]
fn a_failed_write_changes_nothing_in_memory() {
let home = Home::new("ledger-failed-write");
let mut ledger = home.open();
let (a, b) = (turn("chat-1", 1), turn("chat-1", 2));
ledger.append(decided(&a, to_trusted("kyle"))).unwrap();
let kept = home.text();
// The ledger's path turns into a directory: every write to it fails.
std::fs::remove_file(&home.path).unwrap();
std::fs::create_dir(&home.path).unwrap();
let records = [decided(&b, to_trusted("kyle")), sent(&a, &["m-1"])];
for record in records.clone() {
let err = ledger.append(record).unwrap_err();
assert!(matches!(err, LedgerError::Io(..)), "{err:?}");
}
assert!(!ledger.is_decided(&b));
assert_eq!(ledger.sent_ids(&a), None);
assert_eq!(ledger.outbox(), vec![(a.clone(), to_trusted("kyle"))]);
std::fs::remove_dir(&home.path).unwrap();
home.write(&kept);
for record in records {
ledger.append(record).unwrap();
}
let outbox: Vec<Item> = ledger.outbox().into_iter().map(|(i, _)| i).collect();
assert_eq!(outbox, vec![b]);
assert_eq!(home.open().counts().sent, 1);
}
#[test]
fn a_ledger_that_cannot_be_read_is_an_io_error() {
let home = Home::new("ledger-unreadable");
std::fs::create_dir(&home.path).unwrap();
let err = Ledger::open(&home.path).unwrap_err();
assert!(
matches!(&err, LedgerError::Io(p, _) if *p == home.path),
"{err:?}"
);
}
@@ -0,0 +1,123 @@
//! Records for the Honcho ledger's tests, and a ledger in a directory of its own. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::PathBuf;
use bxcore::{SessionId, Timestamp};
use memoryd::honcho::ledger::{Action, Item, Ledger, Record, Workspace};
use crate::tmp::TempDir;
/// A ledger file that does not exist yet, in a directory removed on drop.
pub struct Home {
pub path: PathBuf,
pub dir: TempDir,
}
impl Home {
pub fn new(tag: &str) -> Home {
let dir = TempDir::new(tag);
Home {
path: dir.path().join("honcho.jsonl"),
dir,
}
}
pub fn open(&self) -> Ledger {
Ledger::open(&self.path).unwrap()
}
/// The file's bytes as text; "" when there is no file.
pub fn text(&self) -> String {
std::fs::read_to_string(&self.path).unwrap_or_default()
}
/// Writes `text` as the whole file.
pub fn write(&self, text: &str) {
std::fs::write(&self.path, text).unwrap();
}
}
pub const TIME: &str = "2026-09-28T10:00:00.000Z";
pub fn time() -> Timestamp {
Timestamp::parse(TIME).unwrap()
}
pub fn sid(s: &str) -> SessionId {
SessionId::new(s).unwrap()
}
pub fn turn(session: &str, turn: u64) -> Item {
Item::Turn {
session: sid(session),
turn,
}
}
pub fn edit(id: &str, part: u32) -> Item {
Item::Edit {
id: id.to_string(),
part,
}
}
pub fn to_trusted(speaker: &str) -> Action {
Action::Turn {
workspace: Workspace::Trusted,
speaker: speaker.to_string(),
}
}
pub fn skip(why: &str) -> Action {
Action::Skip {
why: why.to_string(),
}
}
pub fn conclude(workspace: Workspace, text: &str) -> Action {
Action::Conclude {
workspace,
peer: "kyle".to_string(),
session: sid("chat-1"),
text: text.to_string(),
}
}
pub fn unconclude(workspace: Workspace, text: &str) -> Action {
Action::Unconclude {
workspace,
text: text.to_string(),
}
}
pub fn decided(item: &Item, action: Action) -> Record {
Record::Decided {
time: time(),
item: item.clone(),
action,
}
}
pub fn sent(item: &Item, ids: &[&str]) -> Record {
Record::Sent {
time: time(),
item: item.clone(),
ids: ids.iter().map(|s| s.to_string()).collect(),
}
}
pub fn failed(item: &Item, status: Option<u16>) -> Record {
Record::Failed {
time: time(),
item: item.clone(),
status,
kind: "refused".to_string(),
}
}
/// `record`'s line, as `append` writes it.
pub fn line(record: &Record) -> String {
format!("{}\n", serde_json::to_string(record).unwrap())
}
@@ -0,0 +1,37 @@
## honcho-ledger-damaged
**What you see.** `memoryd` logs a line naming `memory/honcho.jsonl`, a line number and a reason,
then this entry:
```text
/srv/bx/memory/honcho.jsonl:12: turn 3 of session chat-1: decided already; see docs/runbook.md#honcho-ledger-damaged
```
**Why.** The ledger is `memoryd`'s record of what it sends to Honcho: one line when a turn or a
memory write is decided (where it goes, or that it goes nowhere), and one when it is sent or
refused (M5c spec, section 5). What is decided and not yet sent is the outbox. `memoryd` reads the
ledger through at start so that it sends nothing twice and nothing to a workspace it was not
decided for. A line it cannot read, or one that breaks the ledger's rules, could do either, so it
does not guess: it does not start its Honcho sync, and nothing is decided or sent to Honcho until
the ledger reads through. The rules: an item is decided once (`decided already`), and it is sent
or refused once, only after it is decided and only when it was not decided to go nowhere (`not
waiting to be sent`). A last line cut off by a crash is not damage: `memoryd` drops it and says so
in its log.
**Confirm.** Look at the line named, and at every line of the same item:
```sh
f="$BOXMAKER_HOME/memory/honcho.jsonl"
sed -n '<line>p' "$f"
grep -n '"session":"<id>","turn":<n>}' "$f" # a turn; for an edit: '"id":"<id>","part":<n>}'
```
**Fix.** Keep a copy first: `cp -a "$f" "$f.damaged-$(date +%s)"`. The ledger is written only by
`memoryd`, so a damaged line was changed by hand or by the disk. If you know what the line should
say, correct it. Otherwise remove it (`sed -i '<line>d' "$f"`), knowing what that costs: a removed
`sent` or `failed` line means the item is sent again (a turn's messages appear twice in Honcho, a
conclusion is made twice); a removed `decided` line means the item is decided again, and any
later `sent` or `failed` line of the same item then breaks the rules, so remove those too. Honcho
cannot delete a message, so a turn sent twice stays twice.
**Check.** `memoryd` starts, and its log has no line with this entry.