feat(evidence): one function decides whether an entry needs evidence (P4)

Accounting reason: the receipt rule (Pub. 583, Rev. Proc. 98-25: the
books must be demonstrably supported by the records) was decided in
three places that could disagree: the server's post, the digest, and a
string match on a journal-event origin in the viewer. ledger_core::
evidence::requirement is now the one decision: Required (with why) or
Exempt (with the source). A reversal needs no receipt of its own (its
evidence is its original's, D-47, D-12); neither do a closing entry, a
schedule month's or a depreciation run's posting, a disposal, a
document's entry, a payroll run or its remittance. The ledger learned
the two origins it could not see (a disposal, a remittance) from the
events that already name their entry, live and on replay. A split group
counts for a draft only: a posted entry's decision was made when it
posted.

The policy moved with it (evidence::Policy; ReceiptPolicy in
ledger-config keeps the words and delegates). Store::post_entry_checked
refuses a covered draft with no receipt (StoreError::ReceiptRequired,
which the MCP maps to the unchanged receipt_required error);
Store::post_entry stays the primitive that commands and fixtures post
through, since ~176 call sites post hand entries with no receipt. The
viewer's missing_receipt calls requirement; its string match is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 13:55:17 -06:00
1 parent 28d46332e4
commit 7299cf90f2
6 files changed
+451 -132

No files matched your search

+18
View File
@@ -35,6 +35,24 @@ pub enum StoreError {
#[error("asset {0} already has depreciation posted; correct it with entries instead")]
AssetDepreciationPosted(u64),
/// The receipt policy covers the entry and it has no receipt (plan P4,
/// I-5; rules.md B-43): refused before anything is journaled.
#[error(
"entry {entry_id} needs a receipt before it posts: {reason} ({policy}); attach one with books.attach_receipt"
)]
ReceiptRequired {
/// The entry that needs the receipt.
entry_id: u64,
/// Its total debits, `None` when they pass exact arithmetic.
debits: Option<rust_decimal::Decimal>,
/// The policy that triggered, in words.
policy: String,
/// Why this entry is covered.
reason: String,
/// The posted entries it appears to split (anti-splitting).
split_group: Vec<u64>,
},
/// Filesystem error.
#[error("io error: {0}")]
Io(#[from] std::io::Error),
+54 -1
View File
@@ -1519,6 +1519,47 @@ impl Store {
self.post_entry_unchecked(entry)
}
/// [`Self::post_entry`] behind the evidence gate (plan P4, I-5, D-47;
/// rules.md B-43): refuses, journaling nothing, a draft that
/// [`ledger_core::evidence::requirement`] says needs evidence while it
/// has no receipt filed on it or linked to it. This is the path the
/// server posts a hand-made draft by; [`Self::post_entry`] stays the
/// primitive that commands and fixtures post through, which carry
/// their evidence another way.
///
/// ```text
/// GOAL: no draft the receipt policy covers posts without the
/// document that supports it, decided in one place.
/// SOURCE: docs/spec/rules.md B-43; IRS Pub. 583.
/// SCENARIOS:
/// 1. IF the entry is covered and has no receipt: SHOULD refuse
/// (ReceiptRequired), NEVER journal.
/// 2. IF it is exempt (a reversal, a document's entry, a close) or
/// has a receipt: SHOULD post as post_entry does.
/// ```
pub fn post_entry_checked(&mut self, entry: EntryId) -> Result<(), StoreError> {
let e = self
.ledger
.get_entry(entry)
.ok_or(LedgerError::UnknownEntry(entry))?
.clone();
if e.status() == EntryStatus::Draft
&& let ledger_core::evidence::Requirement::Required { reason } =
ledger_core::evidence::requirement(&self.ledger, &e, self.ledger.settings())
&& self.list_attachments(Some(entry))?.is_empty()
{
let policy = ledger_core::evidence::Policy::from_settings(self.ledger.settings());
return Err(StoreError::ReceiptRequired {
entry_id: entry,
debits: ledger_core::evidence::Policy::entry_debits(&e).ok(),
policy: policy.describe(),
split_group: policy.split_group(&self.ledger, entry, &e),
reason,
});
}
self.post_entry(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
@@ -2833,6 +2874,8 @@ impl Store {
}),
)?;
self.ledger.dispose_asset(asset_id, date, proceeds)?;
self.ledger
.note_entry_origin(entry_id, ledger_core::evidence::Origin::AssetDisposal);
apply_line_to_sqlite(&self.conn, &line)?;
Ok(entry_id)
}
@@ -4398,6 +4441,8 @@ impl Store {
)?);
let live = self.ledger.remit_payroll_taxes(date, cash_account)?;
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
self.ledger
.note_entry_origin(live, ledger_core::evidence::Origin::PayrollRemittance);
for line in &journaled {
apply_line_to_sqlite(&self.conn, line)?;
}
@@ -7492,7 +7537,11 @@ fn replay_line(
}
"payroll.taxes_remitted" => {
// Names the remittance entry (rebuilt by the entry events just
// before it) for readers; no ledger state of its own.
// before it): the ledger keeps what posted it (plan P4), so
// the evidence gate can tell it from a hand entry.
if let Some(eid) = p.get("entry_id").and_then(Value::as_u64) {
ledger.note_entry_origin(eid, ledger_core::evidence::Origin::PayrollRemittance);
}
}
"asset.registered" => {
let want = get_u64(p, "id")?;
@@ -7663,6 +7712,10 @@ fn replay_line(
parse_date(get_str(p, "date")?)?,
parse_money(p, "proceeds")?,
)?;
// The disposal's own entry, as the journal names it (plan P4).
if let Some(eid) = p.get("entry_id").and_then(Value::as_u64) {
ledger.note_entry_origin(eid, ledger_core::evidence::Origin::AssetDisposal);
}
}
"period.created" => {
let want = get_u64(p, "id")?;