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

+28 -9
View File
@@ -24,7 +24,7 @@ use crate::journal::{JournalLine, ReadLine};
use serde_json::{Value, json};
/// The newest journal format this version reads and writes.
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 1;
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
/// Optional journal features this version reads (none yet).
pub const KNOWN_FEATURES: &[&str] = &[];
@@ -53,8 +53,12 @@ pub const EVENT_TYPES: &[&str] = &[
"bill.created",
"bill.paid",
"class.added",
"convention.retired",
"convention.set",
"customer.added",
"decision.recorded",
"document.filed",
"entry.approved",
"entry.created",
"entry.line_added",
"entry.line_class_set",
@@ -70,6 +74,8 @@ pub const EVENT_TYPES: &[&str] = &[
FORMAT_SET,
"journal.tail_repaired",
"line.cleared",
"open_item.raised",
"open_item.resolved",
"party.added",
"party.role_set",
"party.tin_set",
@@ -90,6 +96,7 @@ pub const EVENT_TYPES: &[&str] = &[
"role.assigned",
"schedule.posted",
"schedule.registered",
"settings.set",
"statement.recorded",
"statement.voided",
"store.created",
@@ -220,6 +227,14 @@ pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError>
Ok(JournalFormat { format, features })
}
/// The refusal for a line of a type this version knows that holds a
/// value only a newer version writes: a setting key, a convention kind
/// (`need` says which). Never "corrupt": the books are fine, this reader
/// is old (rules.md B-27).
pub(crate) fn newer_content(line: &JournalLine, need: String) -> StoreError {
newer(line, need)
}
/// The refusal for a line whose type this version doesn't know.
pub(crate) fn unknown_type(line: &JournalLine) -> StoreError {
newer(line, "an event type this version doesn't know".into())
@@ -347,7 +362,8 @@ mod tests {
"{t}: no projection arm"
);
}
let l = line("settings.set", json!({}));
// A type a later phase adds (plan/served.md 5.2, P8).
let l = line("period.attested", json!({}));
let mut ledger = Ledger::new(Currency::USD);
assert!(is_newer(&replay_line_into_ledger(
&mut ledger,
@@ -366,7 +382,7 @@ mod tests {
at1.declaration(1, &[]).unwrap(),
json!({"format": 1, "features": []})
);
for (format, features) in [(0, &[][..]), (2, &[][..]), (1, &["x"][..])] {
for (format, features) in [(0, &[][..]), (3, &[][..]), (1, &["x"][..])] {
assert!(
matches!(
at1.declaration(format, features),
@@ -393,7 +409,10 @@ mod tests {
.unwrap();
assert_eq!(f, JournalFormat::default());
f.admit(&line("entry.posted", json!({}))).unwrap();
match f.admit(&line(FORMAT_SET, json!({"format": 2, "features": []}))) {
f.admit(&line(FORMAT_SET, json!({"format": 2, "features": []})))
.unwrap();
assert_eq!(f.format, 2, "format 2 is this version's");
match f.admit(&line(FORMAT_SET, json!({"format": 3, "features": []}))) {
Err(StoreError::NewerJournal {
seq,
event_type,
@@ -403,7 +422,7 @@ mod tests {
// The gate alone never vouches for the line's bytes.
assert!(!verified);
assert_eq!((seq, event_type.as_str()), (7, FORMAT_SET));
assert_eq!(need, "journal format 2");
assert_eq!(need, "journal format 3");
}
other => panic!("expected NewerJournal, got {other:?}"),
}
@@ -417,7 +436,7 @@ mod tests {
other => panic!("expected NewerJournal, got {other:?}"),
}
// A refused line leaves the format in force alone.
assert_eq!(f, JournalFormat::default());
assert_eq!(f.format, 2);
for bad in [
json!({"features": []}),
json!({"format": 0, "features": []}),
@@ -444,9 +463,9 @@ mod tests {
fn a_newer_format_is_newer_whatever_its_features_look_like() {
let mut f = JournalFormat::default();
for newer in [
json!({"format": 2, "features": {"actors": true}}),
json!({"format": 2, "features": [1, 2]}),
json!({"format": 2}),
json!({"format": 3, "features": {"actors": true}}),
json!({"format": 3, "features": [1, 2]}),
json!({"format": 3}),
json!({"format": 4_294_967_296_u64, "features": []}),
] {
assert!(
+6
View File
@@ -242,6 +242,12 @@ impl JournalWriter {
self.next_seq
}
/// The head of the chain: the last line's seq (0 for an empty
/// journal) and its hash (the genesis value for an empty one).
pub fn head(&self) -> (u64, &str) {
(self.next_seq - 1, &self.prev_hash)
}
/// Path of the journal file.
pub fn path(&self) -> &Path {
&self.path
+1
View File
@@ -22,6 +22,7 @@ pub mod blob;
pub mod error;
pub mod format;
pub mod journal;
pub mod meta;
pub mod reconcilable;
pub mod schema;
pub mod snapshot;
+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()
);
}
}
+58 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 11;
pub const SCHEMA_VERSION: u32 = 12;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -266,5 +266,62 @@ CREATE TABLE IF NOT EXISTS reconciliation_lines(
line_index INTEGER NOT NULL,
PRIMARY KEY(reconciliation_id, entry_id, line_index)
);
"#,
r#"
-- v12: the books' own record of how they are kept (journal format 2,
-- plan/served.md 5.2): settings in force (each value as canonical JSON,
-- with the seq and source that set it), conventions (the current
-- revision of each, and its retirement), decisions, open items, and
-- approvals of draft entries.
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
seq INTEGER NOT NULL,
source_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS conventions(
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
title TEXT NOT NULL,
body_md TEXT NOT NULL,
structured_json TEXT NULL,
source_json TEXT NOT NULL,
seq INTEGER NOT NULL,
revision INTEGER NOT NULL,
retired_reason TEXT NULL,
retired_source_json TEXT NULL,
retired_seq INTEGER NULL
);
CREATE TABLE IF NOT EXISTS decisions(
id INTEGER PRIMARY KEY,
subject TEXT NOT NULL,
decision_md TEXT NOT NULL,
decided_by_json TEXT NOT NULL,
decided_on TEXT NOT NULL,
evidence_attachment INTEGER NULL,
supersedes INTEGER NULL,
citation TEXT NULL,
seq INTEGER NOT NULL,
superseded_by INTEGER NULL
);
CREATE TABLE IF NOT EXISTS open_items(
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
detail_md TEXT NULL,
due TEXT NULL,
source_json TEXT NOT NULL,
seq INTEGER NOT NULL,
resolution TEXT NULL,
resolved_source_json TEXT NULL,
resolved_seq INTEGER NULL
);
CREATE TABLE IF NOT EXISTS entry_approvals(
seq INTEGER PRIMARY KEY,
entry_id INTEGER NOT NULL,
approver TEXT NOT NULL,
fingerprint TEXT NOT NULL,
note TEXT NULL,
source_json TEXT NOT NULL
);
"#,
];
+491 -5
View File
@@ -519,6 +519,13 @@ impl Store {
self.base_currency
}
/// The journal's head: the last line's seq and its hash (what
/// `verify` reports as the head once it has checked the chain).
pub fn head(&self) -> (u64, String) {
let (seq, hash) = self.journal.head();
(seq, hash.to_string())
}
/// The journal format in force: format 1 unless the books declared
/// another with `journal.format_set`.
pub fn journal_format(&self) -> &JournalFormat {
@@ -554,6 +561,209 @@ impl Store {
Ok(())
}
// ------------------------------------------------------------------
// How the books are kept: settings, conventions, decisions, open
// items, approvals (journal format 2, crate::meta)
// ------------------------------------------------------------------
/// Declare journal format 2 before the first event that needs it, so
/// an older reader stops at the declaration (plan/served.md 5.1).
fn ensure_format(&mut self) -> Result<(), StoreError> {
if self.format.format < crate::meta::FORMAT {
let features = self.format.features.clone();
let features: Vec<&str> = features.iter().map(String::as_str).collect();
self.declare_journal_format(crate::meta::FORMAT, &features)?;
}
Ok(())
}
/// Change settings (`settings.set`): all or nothing, each value
/// canonical, the result whole (ledger_core::settings). Returns the
/// changes made; none (journaling nothing) when every change is the
/// value already set.
pub fn change_settings(
&mut self,
changes: &[ledger_core::settings::Change],
source: &ledger_core::settings::Source,
) -> Result<Vec<ledger_core::settings::Change>, StoreError> {
self.journal_settings(changes, source, false)
}
/// [`Self::change_settings`] for books moving their settings in from a
/// settings file: the first `settings.set` is journaled even when it
/// changes nothing (a file that sets only defaults), so from then on
/// the books keep their own settings and no file is read.
pub fn adopt_settings(
&mut self,
changes: &[ledger_core::settings::Change],
source: &ledger_core::settings::Source,
) -> Result<Vec<ledger_core::settings::Change>, StoreError> {
self.journal_settings(changes, source, true)
}
fn journal_settings(
&mut self,
changes: &[ledger_core::settings::Change],
source: &ledger_core::settings::Source,
adopt: bool,
) -> Result<Vec<ledger_core::settings::Change>, StoreError> {
let changes = self
.ledger
.check_settings_adopting(changes, source, adopt)?;
if changes.is_empty() && !(adopt && !self.ledger.settings().is_journaled()) {
return Ok(changes);
}
self.ensure_format()?;
let line =
self.append_journal("settings.set", crate::meta::settings_set(&changes, source))?;
self.ledger.apply_settings(&changes, source, line.seq)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(changes)
}
/// Set or revise a convention (`convention.set`). Returns it as
/// journaled.
pub fn set_convention(
&mut self,
c: &ledger_core::conventions::NewConvention,
) -> Result<ledger_core::conventions::NewConvention, StoreError> {
let c = self.ledger.check_convention(c)?;
self.ensure_format()?;
let line = self.append_journal("convention.set", crate::meta::convention_set(&c))?;
self.ledger.apply_convention(c.clone(), line.seq)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(c)
}
/// Retire a convention (`convention.retired`).
pub fn retire_convention(
&mut self,
id: &str,
reason: &str,
source: &ledger_core::settings::Source,
) -> Result<(), StoreError> {
let reason = self.ledger.check_retire_convention(id, reason, source)?;
self.ensure_format()?;
let line = self.append_journal(
"convention.retired",
crate::meta::convention_retired(id, &reason, source),
)?;
self.ledger
.apply_retire_convention(id, reason, source.clone(), line.seq)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Record a decision (`decision.recorded`); its evidence, if any, is
/// a filed attachment not withdrawn. Returns its id.
pub fn record_decision(
&mut self,
d: &ledger_core::conventions::NewDecision,
) -> Result<u64, StoreError> {
let (id, d) = self.ledger.check_decision(d)?;
if let Some(a) = d.evidence_attachment
&& self.attachment_state(a)?.1
{
return Err(StoreError::AttachmentWithdrawn(a));
}
self.ensure_format()?;
let line =
self.append_journal("decision.recorded", crate::meta::decision_recorded(id, &d))?;
self.ledger.apply_decision(id, d, line.seq)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(id)
}
/// Raise an open item (`open_item.raised`). Returns its id.
pub fn raise_open_item(
&mut self,
i: &ledger_core::conventions::NewOpenItem,
) -> Result<u64, StoreError> {
let (id, i) = self.ledger.check_open_item(i)?;
self.ensure_format()?;
let line =
self.append_journal("open_item.raised", crate::meta::open_item_raised(id, &i))?;
self.ledger.apply_open_item(id, i, line.seq)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(id)
}
/// Resolve an open item (`open_item.resolved`).
pub fn resolve_open_item(
&mut self,
id: u64,
resolution: &str,
source: &ledger_core::settings::Source,
) -> Result<(), StoreError> {
let text = self.ledger.check_resolve_item(id, resolution, source)?;
self.ensure_format()?;
let line = self.append_journal(
"open_item.resolved",
crate::meta::open_item_resolved(id, &text, source),
)?;
self.ledger
.apply_resolve_item(id, text, source.clone(), line.seq)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Record an approval of a draft entry (`entry.approved`), as the
/// draft stands now.
pub fn approve_entry(
&mut self,
entry: EntryId,
approver: &str,
note: Option<&str>,
source: &ledger_core::settings::Source,
) -> Result<ledger_core::conventions::Approval, StoreError> {
let a = self.ledger.check_approval(entry, approver, note, source)?;
self.ensure_format()?;
let line = self.append_journal("entry.approved", crate::meta::entry_approved(entry, &a))?;
let a = ledger_core::conventions::Approval { seq: line.seq, ..a };
self.ledger.apply_approval(entry, a.clone())?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(a)
}
/// Draft an entry from a posting template: `amount` split by the
/// template's shares (ledger_core::conventions INVARIANT 2), its slots
/// filled from `slots` (slot name → account code), dated
/// `date`, described as given or as the template says. All or
/// nothing: no half-built draft is ever journaled. Returns the draft.
pub fn draft_from_template(
&mut self,
template_id: &str,
date: chrono::NaiveDate,
amount: Decimal,
description: Option<&str>,
slots: &std::collections::BTreeMap<String, String>,
) -> Result<EntryId, StoreError> {
let (default_description, lines) =
self.ledger.template_lines(template_id, amount, slots)?;
let description = description
.map(str::to_string)
.or(default_description)
.unwrap_or_else(|| {
self.ledger
.conventions()
.convention(template_id)
.map(|c| c.title.clone())
.unwrap_or_default()
});
self.rehearse(|l| {
let e = l.try_create_entry(date, &description)?;
for (account, side, amount) in &lines {
l.add_line(e, *account, *side, *amount)?;
}
Ok(e)
})?;
let e = self.create_entry(date, &description)?;
for (account, side, amount) in lines {
self.add_line(e, account, side, amount)?;
}
Ok(e)
}
/// Every torn journal tail set aside on open, oldest first, as
/// (when, bytes, file it was saved to).
pub fn tail_repairs(&self) -> Result<Vec<(String, u64, String)>, StoreError> {
@@ -685,8 +895,75 @@ impl Store {
Ok(())
}
/// Validate, journal, and post a draft entry.
/// Validate, journal, and post a draft entry. An entry an enforced
/// approval rule covers posts only once its approvers approved it as
/// it stands (ledger_core::conventions INVARIANT 3).
pub fn post_entry(&mut self, entry: EntryId) -> Result<(), StoreError> {
let missing = self.ledger.approvals_missing(entry)?;
if !missing.is_empty() {
return Err(LedgerError::ApprovalRequired {
entry,
missing: missing
.iter()
.map(|(rule, approver)| format!("{rule} (approver: {approver})"))
.collect::<Vec<_>>()
.join(", "),
}
.into());
}
self.post_entry_unchecked(entry)
}
/// Approval rules (ledger_core::conventions INVARIANT 3): refuse an
/// operation that would post at once an entry an enforced rule covers
/// (such an entry has no draft to approve: it must be drafted and
/// approved instead). `last_only`: only the operation's last posted
/// entry is its own (a disposal's; the depreciation before it follows
/// from the books and is exempt). Changes nothing.
fn approval_gate<T>(
&self,
op: impl FnOnce(&mut Ledger) -> Result<T, LedgerError>,
last_only: bool,
) -> Result<(), StoreError> {
if self.ledger.settings().approval_rules() == ledger_core::settings::ApprovalMode::Off
|| self.ledger.conventions().approval_rules().next().is_none()
{
return Ok(());
}
let mut probe = self.ledger.clone();
let first = probe.next_entry_id();
op(&mut probe)?;
let posted: Vec<EntryId> = (first..probe.next_entry_id())
.filter(|id| {
probe
.get_entry(*id)
.is_some_and(|e| e.status() == EntryStatus::Posted)
})
.collect();
let own = if last_only {
posted.last().map(std::slice::from_ref).unwrap_or(&[])
} else {
&posted[..]
};
for &id in own {
let missing = probe.approvals_missing(id)?;
if !missing.is_empty() {
return Err(LedgerError::ApprovalNeedsDraft(
missing
.iter()
.map(|(rule, approver)| format!("{rule} (approver: {approver})"))
.collect::<Vec<_>>()
.join(", "),
)
.into());
}
}
Ok(())
}
/// [`Self::post_entry`] for entries the store makes and posts itself
/// (depreciation, a disposal, whose own gate ran first).
fn post_entry_unchecked(&mut self, entry: EntryId) -> Result<(), StoreError> {
self.rehearse(|l| l.post_entry(entry))?;
let snap = self
.ledger
@@ -1157,7 +1434,7 @@ impl Store {
Side::Credit,
Money::new(amount, self.base_currency),
)?;
self.post_entry(entry_id)?;
self.post_entry_unchecked(entry_id)?;
let dline = self.append_journal(
"asset.depreciation_posted",
json!({
@@ -1396,12 +1673,27 @@ impl Store {
}
l.dispose_asset(asset_id, date, proceeds)
})?;
// An enforced approval rule covering the disposal entry (the last
// posted) refuses it before anything is journaled.
self.approval_gate(
|l| {
for (description, lines) in depreciation.iter().chain([&disposal]) {
let e = l.try_create_entry(date, description)?;
for (account, side, amount) in lines {
l.add_line(e, *account, *side, Money::new(*amount, base))?;
}
l.post_entry(e)?;
}
l.dispose_asset(asset_id, date, proceeds)
},
true,
)?;
if let Some((description, lines)) = &depreciation {
let eid = self.create_entry(date, description)?;
for (account, side, amount) in lines {
self.add_line(eid, *account, *side, Money::new(*amount, base))?;
}
self.post_entry(eid)?;
self.post_entry_unchecked(eid)?;
let dline = self.append_journal(
"asset.depreciation_posted",
json!({
@@ -1417,7 +1709,7 @@ impl Store {
for (account, side, amount) in &disposal.1 {
self.add_line(entry_id, *account, *side, Money::new(*amount, base))?;
}
self.post_entry(entry_id)?;
self.post_entry_unchecked(entry_id)?;
let line = self.append_journal(
"asset.disposed",
json!({
@@ -1741,6 +2033,10 @@ impl Store {
lines: Vec<DocumentLine>,
) -> Result<InvoiceId, StoreError> {
self.rehearse(|l| l.create_invoice(customer_id, date, due_date, lines.clone()))?;
self.approval_gate(
|l| l.create_invoice(customer_id, date, due_date, lines.clone()),
false,
)?;
// Pure pre-validation mirroring Ledger::create_invoice, so the
// journal writes below cannot be followed by a failed mutation.
// (The auto-posted entry balances by construction and its accounts
@@ -1801,6 +2097,10 @@ impl Store {
lines: Vec<DocumentLine>,
) -> Result<BillId, StoreError> {
self.rehearse(|l| l.create_bill(vendor_id, date, due_date, lines.clone()))?;
self.approval_gate(
|l| l.create_bill(vendor_id, date, due_date, lines.clone()),
false,
)?;
if self.ledger.get_vendor(vendor_id).is_none() {
return Err(LedgerError::UnknownVendor(vendor_id).into());
}
@@ -1852,6 +2152,10 @@ impl Store {
cash_account: AccountId,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.receive_payment(invoice_id, date, amount, cash_account))?;
self.approval_gate(
|l| l.receive_payment(invoice_id, date, amount, cash_account),
false,
)?;
// The rehearsal above is the whole check: hand-written mirrors of
// the engine's refusals drift from it (and were never reached).
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
@@ -1888,6 +2192,7 @@ impl Store {
cash_account: AccountId,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.pay_bill(bill_id, date, amount, cash_account))?;
self.approval_gate(|l| l.pay_bill(bill_id, date, amount, cash_account), false)?;
let ap = self.ensure_control_account(&ledger_core::PAYABLES)?;
let description = format!("Payment on bill {bill_id}");
let (entry_id, mut journaled) =
@@ -2023,6 +2328,7 @@ impl Store {
cash_account: AccountId,
) -> Result<PayrollRunId, StoreError> {
self.rehearse(|l| l.run_payroll(date, lines.clone(), cash_account))?;
self.approval_gate(|l| l.run_payroll(date, lines.clone(), cash_account), false)?;
// Pure pre-validation mirroring Ledger::run_payroll, so the journal
// writes below cannot be followed by a failed mutation.
self.ledger.validate_payroll(date, &lines, cash_account)?;
@@ -2101,6 +2407,7 @@ impl Store {
cash_account: AccountId,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.remit_payroll_taxes(date, cash_account))?;
self.approval_gate(|l| l.remit_payroll_taxes(date, cash_account), false)?;
// Pure pre-validation mirroring Ledger::remit_payroll_taxes.
if self.ledger.chart().get(cash_account).is_none() {
return Err(LedgerError::UnknownAccount(cash_account).into());
@@ -2943,7 +3250,7 @@ impl Store {
.iter()
.any(|b| b.abs() > ledger_core::kernel::MAX_LINE_AMOUNT)
{
return bad("balances are at most 1000000000000000");
return bad("balances are at most one quadrillion");
}
if !ledger_core::calendar::in_calendar(st.period_start)
|| !ledger_core::calendar::in_calendar(st.period_end)
@@ -4538,6 +4845,82 @@ pub(crate) fn replay_line_into_ledger(
p.get("class_id").and_then(|v| v.as_u64()).map(|v| v as u32),
)?;
}
"settings.set" => {
let (changes, source) = crate::meta::settings_set_of(p)?;
// A key this version doesn't know: a newer one wrote it.
if let Some(c) = changes
.iter()
.find(|c| ledger_core::settings::key(&c.key).is_none())
{
return Err(crate::format::newer_content(
line,
format!("the setting {}", c.key),
));
}
ledger.apply_settings(&changes, &source, line.seq)?;
}
"convention.set" => {
// A kind this version doesn't know: a newer one wrote it.
if let Some(kind) = p.get("kind").and_then(Value::as_str)
&& ledger_core::conventions::ConventionKind::parse(kind).is_none()
{
return Err(crate::format::newer_content(
line,
format!("the convention kind {kind}"),
));
}
let c = crate::meta::convention_set_of(p)?;
// A template or rule whose structure this version can't read:
// a newer one wrote it (never skipped: a rule must not fail
// open).
let probe = ledger_core::conventions::Convention {
id: c.id.clone(),
kind: c.kind,
title: String::new(),
body_md: String::new(),
structured: c.structured.clone(),
source: c.source.clone(),
seq: line.seq,
revision: 1,
retired: None,
};
let unreadable = match c.kind {
ledger_core::conventions::ConventionKind::PostingTemplate => {
probe.template().is_none()
}
ledger_core::conventions::ConventionKind::ApprovalRule => {
probe.approval_rule().is_none()
}
_ => false,
};
if unreadable {
return Err(crate::format::newer_content(
line,
format!("the structure of convention {}", c.id),
));
}
ledger.apply_convention(c, line.seq)?;
}
"convention.retired" => {
let (id, reason, source) = crate::meta::convention_retired_of(p)?;
ledger.apply_retire_convention(&id, reason, source, line.seq)?;
}
"decision.recorded" => {
let (id, d) = crate::meta::decision_recorded_of(p)?;
ledger.apply_decision(id, d, line.seq)?;
}
"open_item.raised" => {
let (id, i) = crate::meta::open_item_raised_of(p)?;
ledger.apply_open_item(id, i, line.seq)?;
}
"open_item.resolved" => {
let (id, resolution, source) = crate::meta::open_item_resolved_of(p)?;
ledger.apply_resolve_item(id, resolution, source, line.seq)?;
}
"entry.approved" => {
let (entry, approval) = crate::meta::entry_approved_of(p, line.seq)?;
ledger.apply_approval(entry, approval)?;
}
"journal.tail_repaired" => {}
"journal.format_set" => {
// No ledger state; a format this version can't read is
@@ -5325,6 +5708,109 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
],
)?;
}
"settings.set" => {
let (changes, source) = crate::meta::settings_set_of(p)?;
let source = crate::meta::source_json(&source).to_string();
for c in &changes {
match &c.value {
Some(v) => conn.execute(
"INSERT INTO settings(key, value_json, seq, source_json) VALUES (?,?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, seq=excluded.seq, source_json=excluded.source_json",
params![c.key, v.to_string(), line.seq as i64, source],
)?,
None => conn.execute("DELETE FROM settings WHERE key = ?", params![c.key])?,
};
}
}
"convention.set" => {
let c = crate::meta::convention_set_of(p)?;
conn.execute(
"INSERT INTO conventions(id, kind, title, body_md, structured_json, source_json, seq, revision) VALUES (?,?,?,?,?,?,?,1) ON CONFLICT(id) DO UPDATE SET kind=excluded.kind, title=excluded.title, body_md=excluded.body_md, structured_json=excluded.structured_json, source_json=excluded.source_json, seq=excluded.seq, revision=conventions.revision + 1",
params![
c.id,
c.kind.key(),
c.title,
c.body_md,
c.structured.as_ref().map(Value::to_string),
crate::meta::source_json(&c.source).to_string(),
line.seq as i64,
],
)?;
}
"convention.retired" => {
let (id, reason, source) = crate::meta::convention_retired_of(p)?;
conn.execute(
"UPDATE conventions SET retired_reason = ?, retired_source_json = ?, retired_seq = ? WHERE id = ?",
params![
reason,
crate::meta::source_json(&source).to_string(),
line.seq as i64,
id
],
)?;
}
"decision.recorded" => {
let (id, d) = crate::meta::decision_recorded_of(p)?;
conn.execute(
"INSERT INTO decisions(id, subject, decision_md, decided_by_json, decided_on, evidence_attachment, supersedes, citation, seq) VALUES (?,?,?,?,?,?,?,?,?)",
params![
id as i64,
d.subject,
d.decision_md,
serde_json::to_string(&d.decided_by)?,
d.decided_on.to_string(),
d.evidence_attachment.map(|a| a as i64),
d.supersedes.map(|a| a as i64),
d.citation,
line.seq as i64,
],
)?;
if let Some(old) = d.supersedes {
conn.execute(
"UPDATE decisions SET superseded_by = ? WHERE id = ?",
params![id as i64, old as i64],
)?;
}
}
"open_item.raised" => {
let (id, i) = crate::meta::open_item_raised_of(p)?;
conn.execute(
"INSERT INTO open_items(id, title, detail_md, due, source_json, seq) VALUES (?,?,?,?,?,?)",
params![
id as i64,
i.title,
i.detail_md,
i.due.map(|d| d.to_string()),
crate::meta::source_json(&i.source).to_string(),
line.seq as i64,
],
)?;
}
"open_item.resolved" => {
let (id, resolution, source) = crate::meta::open_item_resolved_of(p)?;
conn.execute(
"UPDATE open_items SET resolution = ?, resolved_source_json = ?, resolved_seq = ? WHERE id = ?",
params![
resolution,
crate::meta::source_json(&source).to_string(),
line.seq as i64,
id as i64
],
)?;
}
"entry.approved" => {
let (entry, a) = crate::meta::entry_approved_of(p, line.seq)?;
conn.execute(
"INSERT INTO entry_approvals(seq, entry_id, approver, fingerprint, note, source_json) VALUES (?,?,?,?,?,?)",
params![
line.seq as i64,
entry as i64,
a.approver,
a.fingerprint,
a.note,
crate::meta::source_json(&a.source).to_string(),
],
)?;
}
_ => return Err(crate::format::unknown_type(line)),
}
conn.execute(