P1: the books keep how they are kept

Settings, conventions (posting templates, approval rules, policies,
close steps, calendar, mappings, notes), decisions, open items and
entry approvals are journal events (journal format 2). Books that keep
their settings never read wanderbooks.toml or config.json; the server
and the viewer take the vault key from --vault-key-file instead.
books.import_settings moves a settings file in once, exactly as the
server reads it. Approval rules, when enforced, gate every posting
path. Replay refuses what it can't read as newer, never as corrupt.

Lean: a record changes no figure. Mutation on the whole diff: every
survivor killed by a test but two equivalent (docs/testing.md). The
Company page says a late open item is overdue in words, not colour
alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-17 19:27:48 -06:00
1 parent e60ecc5317
commit c587593d78
71 files changed
+12249 -461

No files matched your search

+380
View File
@@ -0,0 +1,380 @@
//! Journal payloads of the books' own record of how they are kept:
//! settings, conventions, decisions, open items and approvals (journal
//! format 2, plan/served.md 5.2). One encoder and one decoder per event,
//! so the writer and every reader (replay, the projection, the viewer)
//! read a line the same way.
//!
//! ```text
//! GOAL: every event round-trips: decode(encode(x)) == x.
//! INVARIANTS:
//! 1. A payload missing a field, or with a field of the wrong type, is
//! JournalCorrupt; optional fields may be absent or null.
//! 2. Decoding checks shape only (rules.md B-28): what a command checks
//! (limits, the chart) stays with the command.
//! ```
use crate::error::StoreError;
use chrono::NaiveDate;
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
use ledger_core::settings::{Change, Source};
use serde_json::{Map, Value, json};
/// The journal format these events need: an older reader stops at the
/// declaration (format gate) instead of misreading them.
pub const FORMAT: u32 = 2;
fn corrupt(event: &str, what: &str) -> StoreError {
StoreError::JournalCorrupt(format!("{event}: missing or invalid '{what}'"))
}
fn str_of(p: &Value, event: &str, k: &str) -> Result<String, StoreError> {
p.get(k)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| corrupt(event, k))
}
fn opt_str(p: &Value, event: &str, k: &str) -> Result<Option<String>, StoreError> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(corrupt(event, k)),
}
}
fn u64_of(p: &Value, event: &str, k: &str) -> Result<u64, StoreError> {
p.get(k)
.and_then(Value::as_u64)
.ok_or_else(|| corrupt(event, k))
}
fn opt_u64(p: &Value, event: &str, k: &str) -> Result<Option<u64>, StoreError> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().map(Some).ok_or_else(|| corrupt(event, k)),
}
}
fn date_of(s: &str, event: &str, k: &str) -> Result<NaiveDate, StoreError> {
NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| corrupt(event, k))
}
fn opt_date(p: &Value, event: &str, k: &str) -> Result<Option<NaiveDate>, StoreError> {
opt_str(p, event, k)?
.map(|s| date_of(&s, event, k))
.transpose()
}
/// Insert `v` under `k` unless it is `None`.
fn put<T: Into<Value>>(o: &mut Map<String, Value>, k: &str, v: Option<T>) {
if let Some(v) = v {
o.insert(k.to_string(), v.into());
}
}
/// A source as journaled: `{decided_by, decided_on, citation?, note?}`.
pub fn source_json(s: &Source) -> Value {
let mut o = Map::new();
o.insert("decided_by".into(), json!(s.decided_by));
o.insert("decided_on".into(), json!(s.decided_on.to_string()));
put(&mut o, "citation", s.citation.clone());
put(&mut o, "note", s.note.clone());
Value::Object(o)
}
/// A journaled source (`p[k]`).
pub fn source_of(p: &Value, event: &str, k: &str) -> Result<Source, StoreError> {
let s = p
.get(k)
.filter(|v| v.is_object())
.ok_or_else(|| corrupt(event, k))?;
Ok(Source {
decided_by: str_of(s, event, "decided_by")?,
decided_on: date_of(&str_of(s, event, "decided_on")?, event, "source.decided_on")?,
citation: opt_str(s, event, "citation")?,
note: opt_str(s, event, "note")?,
})
}
/// `settings.set {changes: [{key, value|null}], source}`.
pub fn settings_set(changes: &[Change], source: &Source) -> Value {
json!({
"changes": changes
.iter()
.map(|c| json!({ "key": c.key, "value": c.value.clone().unwrap_or(Value::Null) }))
.collect::<Vec<_>>(),
"source": source_json(source),
})
}
/// Read a `settings.set`.
pub fn settings_set_of(p: &Value) -> Result<(Vec<Change>, Source), StoreError> {
const E: &str = "settings.set";
let changes = p
.get("changes")
.and_then(Value::as_array)
.ok_or_else(|| corrupt(E, "changes"))?
.iter()
.map(|c| {
Ok(Change {
key: str_of(c, E, "key")?,
value: match c.get("value") {
None => return Err(corrupt(E, "value")),
Some(Value::Null) => None,
Some(v) => Some(v.clone()),
},
})
})
.collect::<Result<Vec<_>, StoreError>>()?;
Ok((changes, source_of(p, E, "source")?))
}
/// `convention.set {id, kind, title, body_md, structured?, source}`.
pub fn convention_set(c: &NewConvention) -> Value {
let mut o = Map::new();
o.insert("id".into(), json!(c.id));
o.insert("kind".into(), json!(c.kind.key()));
o.insert("title".into(), json!(c.title));
o.insert("body_md".into(), json!(c.body_md));
put(&mut o, "structured", c.structured.clone());
o.insert("source".into(), source_json(&c.source));
Value::Object(o)
}
/// Read a `convention.set`.
pub fn convention_set_of(p: &Value) -> Result<NewConvention, StoreError> {
const E: &str = "convention.set";
Ok(NewConvention {
id: str_of(p, E, "id")?,
kind: ConventionKind::parse(&str_of(p, E, "kind")?).ok_or_else(|| corrupt(E, "kind"))?,
title: str_of(p, E, "title")?,
body_md: str_of(p, E, "body_md")?,
structured: p.get("structured").filter(|v| !v.is_null()).cloned(),
source: source_of(p, E, "source")?,
})
}
/// `convention.retired {id, reason, source}`.
pub fn convention_retired(id: &str, reason: &str, source: &Source) -> Value {
json!({ "id": id, "reason": reason, "source": source_json(source) })
}
/// Read a `convention.retired`: `(id, reason, source)`.
pub fn convention_retired_of(p: &Value) -> Result<(String, String, Source), StoreError> {
const E: &str = "convention.retired";
Ok((
str_of(p, E, "id")?,
str_of(p, E, "reason")?,
source_of(p, E, "source")?,
))
}
/// `decision.recorded {id, subject, decision_md, decided_by, decided_on,
/// evidence_attachment?, supersedes?, citation?}`.
pub fn decision_recorded(id: u64, d: &NewDecision) -> Value {
let mut o = Map::new();
o.insert("id".into(), json!(id));
o.insert("subject".into(), json!(d.subject));
o.insert("decision_md".into(), json!(d.decision_md));
o.insert("decided_by".into(), json!(d.decided_by));
o.insert("decided_on".into(), json!(d.decided_on.to_string()));
put(&mut o, "evidence_attachment", d.evidence_attachment);
put(&mut o, "supersedes", d.supersedes);
put(&mut o, "citation", d.citation.clone());
Value::Object(o)
}
/// Read a `decision.recorded`: `(id, decision)`.
pub fn decision_recorded_of(p: &Value) -> Result<(u64, NewDecision), StoreError> {
const E: &str = "decision.recorded";
let decided_by = p
.get("decided_by")
.and_then(Value::as_array)
.and_then(|a| {
a.iter()
.map(|v| v.as_str().map(str::to_string))
.collect::<Option<Vec<_>>>()
})
.ok_or_else(|| corrupt(E, "decided_by"))?;
Ok((
u64_of(p, E, "id")?,
NewDecision {
subject: str_of(p, E, "subject")?,
decision_md: str_of(p, E, "decision_md")?,
decided_by,
decided_on: date_of(&str_of(p, E, "decided_on")?, E, "decided_on")?,
evidence_attachment: opt_u64(p, E, "evidence_attachment")?,
supersedes: opt_u64(p, E, "supersedes")?,
citation: opt_str(p, E, "citation")?,
},
))
}
/// `open_item.raised {id, title, detail_md?, due?, source}`.
pub fn open_item_raised(id: u64, i: &NewOpenItem) -> Value {
let mut o = Map::new();
o.insert("id".into(), json!(id));
o.insert("title".into(), json!(i.title));
put(&mut o, "detail_md", i.detail_md.clone());
put(&mut o, "due", i.due.map(|d| d.to_string()));
o.insert("source".into(), source_json(&i.source));
Value::Object(o)
}
/// Read an `open_item.raised`: `(id, item)`.
pub fn open_item_raised_of(p: &Value) -> Result<(u64, NewOpenItem), StoreError> {
const E: &str = "open_item.raised";
Ok((
u64_of(p, E, "id")?,
NewOpenItem {
title: str_of(p, E, "title")?,
detail_md: opt_str(p, E, "detail_md")?,
due: opt_date(p, E, "due")?,
source: source_of(p, E, "source")?,
},
))
}
/// `open_item.resolved {id, resolution, source}`.
pub fn open_item_resolved(id: u64, resolution: &str, source: &Source) -> Value {
json!({ "id": id, "resolution": resolution, "source": source_json(source) })
}
/// Read an `open_item.resolved`: `(id, resolution, source)`.
pub fn open_item_resolved_of(p: &Value) -> Result<(u64, String, Source), StoreError> {
const E: &str = "open_item.resolved";
Ok((
u64_of(p, E, "id")?,
str_of(p, E, "resolution")?,
source_of(p, E, "source")?,
))
}
/// `entry.approved {entry_id, approver, fingerprint, note?, source}`.
pub fn entry_approved(entry: u64, a: &Approval) -> Value {
let mut o = Map::new();
o.insert("entry_id".into(), json!(entry));
o.insert("approver".into(), json!(a.approver));
o.insert("fingerprint".into(), json!(a.fingerprint));
put(&mut o, "note", a.note.clone());
o.insert("source".into(), source_json(&a.source));
Value::Object(o)
}
/// Read an `entry.approved` at `seq`: `(entry, approval)`.
pub fn entry_approved_of(p: &Value, seq: u64) -> Result<(u64, Approval), StoreError> {
const E: &str = "entry.approved";
Ok((
u64_of(p, E, "entry_id")?,
Approval {
approver: str_of(p, E, "approver")?,
fingerprint: str_of(p, E, "fingerprint")?,
note: opt_str(p, E, "note")?,
source: source_of(p, E, "source")?,
seq,
},
))
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::conventions::ConventionKind;
fn src() -> Source {
Source {
decided_by: "A".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
citation: Some("c".into()),
note: None,
}
}
/// GOAL: every event round-trips.
#[test]
fn round_trips() {
let ch = vec![
Change {
key: "company.name".into(),
value: Some(json!("X")),
},
Change {
key: "books.close_into".into(),
value: None,
},
];
assert_eq!(
settings_set_of(&settings_set(&ch, &src())).unwrap(),
(ch, src())
);
let c = NewConvention {
id: "t".into(),
kind: ConventionKind::PostingTemplate,
title: "T".into(),
body_md: "B".into(),
structured: Some(json!({"lines": []})),
source: src(),
};
assert_eq!(convention_set_of(&convention_set(&c)).unwrap(), c);
assert_eq!(
convention_retired_of(&convention_retired("t", "r", &src())).unwrap(),
("t".into(), "r".into(), src())
);
let d = NewDecision {
subject: "S".into(),
decision_md: "D".into(),
decided_by: vec!["A".into(), "B".into()],
decided_on: src().decided_on,
evidence_attachment: Some(7),
supersedes: None,
citation: None,
};
assert_eq!(
decision_recorded_of(&decision_recorded(3, &d)).unwrap(),
(3, d)
);
let i = NewOpenItem {
title: "T".into(),
detail_md: Some("d".into()),
due: Some(src().decided_on),
source: src(),
};
assert_eq!(
open_item_raised_of(&open_item_raised(4, &i)).unwrap(),
(4, i)
);
assert_eq!(
open_item_resolved_of(&open_item_resolved(4, "done", &src())).unwrap(),
(4, "done".into(), src())
);
let a = Approval {
approver: "A".into(),
fingerprint: "00ff".into(),
note: None,
source: src(),
seq: 9,
};
assert_eq!(
entry_approved_of(&entry_approved(5, &a), 9).unwrap(),
(5, a)
);
}
/// INVARIANT 1.
#[test]
fn bad_shapes_are_corrupt() {
assert!(
settings_set_of(&json!({"changes": [{"key": "k"}], "source": source_json(&src())}))
.is_err()
);
assert!(settings_set_of(&json!({"changes": []})).is_err());
assert!(convention_set_of(&json!({"id": "t", "kind": "nope", "title": "T", "body_md": "B", "source": source_json(&src())})).is_err());
assert!(decision_recorded_of(&json!({"id": 1, "subject": "S", "decision_md": "D", "decided_by": [1], "decided_on": "2026-10-17"})).is_err());
assert!(
open_item_raised_of(
&json!({"id": 1, "title": "T", "due": 5, "source": source_json(&src())})
)
.is_err()
);
}
}