feat(core,mcp,gui): never apply a legal rule past what wanderbooks knows

Stephen (2026-10-13): when what wanderbooks knows is stale, check the
date, research it or ask the owner or CPA, and update the rule for
everyone with a pull request (best) or an issue (worst).

- ledger_core::rules: a registry of rules set by law that change over
  time. Each says the years it's known for, its rules.md entry, its
  vendored official source, and a review date. stale_rules(today) is
  pure; the engine has no clock.
- The 1099 threshold is the first: $600 through 2025, $2,000 in 2026,
  then indexed for inflation (26 U.S.C. §6041(h)). default_threshold
  and vendor_1099_report now return Result; for 2027+ they refuse with
  LedgerError::RuleNotKnown, whose message says what to do: get the
  figure from the official source or the owner/CPA, pass it explicitly,
  then open a PR (or issue) at github.com/swaits/wanderbooks. Before,
  2027+ silently used $2,000.
- MCP: books.vendor_1099 relays that refusal; books.digest gains
  stale_rules; the server instructions tell the agent the procedure (and
  how to handle control-account conflicts: explain, never renumber).
  The CPA package writes a NOT-INCLUDED note instead of a guessed 1099
  worksheet.
- Viewer: "Needs attention" shows each stale rule with a ready prompt
  for the agent; the 1099 page explains instead of guessing.

Review date for the 1099 rule: 2026-11-01, when the IRS usually
publishes next year's inflation adjustments (CONVENTION, marked as such
in the registry).

Tests: an_unknown_year_threshold_is_never_guessed,
dated_rules_go_stale_on_their_review_date (dates passed in, so no clock
in the test), MCP stdio: 2027 refused with guidance, explicit threshold
accepted, digest carries stale_rules (asserted date-independently).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 14:07:22 -06:00
1 parent e2bebce051
commit df38dcccf9
15 files changed
+349 -47

No files matched your search

+21
View File
@@ -102,6 +102,27 @@ pub enum LedgerError {
placeholder: bool,
},
/// A rule set by law (a dollar threshold, a rate) isn't known to
/// wanderbooks for the year asked; the engine never guesses (see
/// [`crate::rules`]).
#[error(
"wanderbooks knows the {rule} only through {known_through}, not for {year}. \
Find the {year} figure from the official source (see {spec}) or ask the books' \
owner or CPA, and pass it explicitly. Then update the rule for everyone: open a \
pull request (or at least an issue) at https://github.com/swaits/wanderbooks with \
the new value and its official source."
)]
RuleNotKnown {
/// The rule's plain name.
rule: String,
/// The year asked for.
year: i32,
/// The last year the rule is known for.
known_through: i32,
/// The vendored official source of the rule.
spec: String,
},
/// The referenced journal entry does not exist.
#[error("entry {0} does not exist")]
UnknownEntry(u64),
+39 -13
View File
@@ -12,7 +12,7 @@
//! Thresholds are set by law and change; [`default_threshold`] encodes
//! them and the report takes an override. Confirm with the CPA.
use crate::{AccountType, Ledger, VendorId};
use crate::{AccountType, Ledger, LedgerError, VendorId};
use chrono::Datelike;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@@ -47,17 +47,40 @@ impl Form1099 {
}
}
/// The reporting threshold for payments made in `year`: $600 through 2025;
/// $2,000 from 2026 (One Big Beautiful Bill Act), indexed for inflation
/// after 2026, so later years return $2,000 until updated. Royalties
/// (1099-MISC) have a $10 threshold this doesn't model. Pass an explicit
/// threshold to override.
pub fn default_threshold(year: i32) -> Decimal {
if year <= 2025 {
/// The reporting threshold for payments made in `year`.
///
/// ```text
/// GOAL: the dollar amount at which a vendor's payments become reportable.
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a), (h), §6041A(a)(2),
/// Pub. L. 119-21 §70433; sources/irs-1099-thresholds.md).
/// SCENARIOS:
/// 1. IF `year` <= 2025: SHOULD be $600.
/// 2. IF `year` == 2026: SHOULD be $2,000.
/// 3. IF `year` > rules::THRESHOLD_1099.known_through:
/// - SHOULD refuse with RuleNotKnown
/// - NEVER guess $2,000
/// BECAUSE the amount is indexed for inflation after 2026 and not
/// yet known (Stephen, 2026-10-13: never apply stale rules).
/// ```
///
/// Royalties (1099-MISC, $10) and attorneys' gross proceeds ($600) are not
/// modeled. Pass an explicit threshold to [`vendor_1099_report`] to
/// override.
pub fn default_threshold(year: i32) -> Result<Decimal, LedgerError> {
let rule = crate::rules::THRESHOLD_1099;
if year > rule.known_through {
return Err(LedgerError::RuleNotKnown {
rule: rule.name.to_string(),
year,
known_through: rule.known_through,
spec: rule.source.to_string(),
});
}
Ok(if year <= 2025 {
Decimal::new(600, 0)
} else {
Decimal::new(2000, 0)
}
})
}
/// One vendor's year.
@@ -94,8 +117,11 @@ pub fn vendor_1099_report(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
) -> Vendor1099Report {
let threshold = threshold.unwrap_or_else(|| default_threshold(year));
) -> Result<Vendor1099Report, LedgerError> {
let threshold = match threshold {
Some(t) => t,
None => default_threshold(year)?,
};
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
for entry in ledger.posted_entries() {
if entry.date().year() != year {
@@ -137,9 +163,9 @@ pub fn vendor_1099_report(
})
.collect();
vendors.sort_by(|a, b| a.name.cmp(&b.name).then(a.vendor_id.cmp(&b.vendor_id)));
Vendor1099Report {
Ok(Vendor1099Report {
year,
threshold,
vendors,
}
})
}
+1
View File
@@ -33,6 +33,7 @@ pub mod periods;
#[cfg(kani)]
mod proofs;
pub mod reports;
pub mod rules;
pub mod schedules;
pub mod tax;
+79
View File
@@ -0,0 +1,79 @@
//! Rules set by law that change over time, and how far wanderbooks knows
//! them.
//!
//! The engine has no clock, so it never decides on its own that a rule is
//! out of date. Instead each dated rule says which years it is known for
//! and when it is due for review; callers that know today's date (the MCP
//! server, the viewer) ask [`stale_rules`] and surface what is due.
//!
//! ```text
//! GOAL: never apply a legal amount for a year it isn't known for, and say
//! so before anyone needs it.
//! SOURCE: DECISION (Stephen, 2026-10-13): when what wanderbooks knows is
//! stale, research it or ask the owner or CPA, and update the rule
//! for everyone (a pull request, or at least an issue, on
//! github.com/swaits/wanderbooks).
//! INVARIANTS:
//! 1. NEVER returns a rule's value for a year past `known_through`.
//! 2. MUST report a rule as stale from its review date on.
//! ```
//!
//! To update a rule: vendor the new official text beside the old in its
//! `source` file (docs/spec/README.md, "Vendoring rules"), extend the
//! rule's table and `known_through`, move `review_after` to when the next
//! change is expected, and say so in the commit.
use chrono::NaiveDate;
/// A rule set by law whose value changes over time.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DatedRule {
/// Plain name, e.g. "1099 reporting threshold".
pub name: &'static str,
/// Its entry in docs/spec/rules.md, e.g. "B-19".
pub catalog: &'static str,
/// The vendored official text it comes from.
pub source: &'static str,
/// The last year wanderbooks knows the rule for.
pub known_through: i32,
/// When to look again, as (year, month, day): the day the next value
/// is expected to be published or to take effect.
pub review: (i32, u32, u32),
/// Why that date, and where the next value will come from.
pub review_why: &'static str,
}
impl DatedRule {
/// The review date.
pub fn review_after(&self) -> NaiveDate {
let (y, m, d) = self.review;
NaiveDate::from_ymd_opt(y, m, d).expect("rule review dates are valid")
}
/// Whether the rule is due for review on `today`.
pub fn is_stale(&self, today: NaiveDate) -> bool {
today >= self.review_after()
}
}
/// The 1099-NEC / 1099-MISC reporting threshold (rules.md B-19): $600 for
/// payments through 2025, $2,000 in 2026, then indexed for inflation.
pub const THRESHOLD_1099: DatedRule = DatedRule {
name: "1099 reporting threshold",
catalog: "B-19",
source: "docs/spec/sources/irs-1099-thresholds.md",
known_through: 2026,
review: (2026, 11, 1),
review_why: "26 U.S.C. §6041(h) indexes the threshold for calendar years after 2026, \
rounded to the nearest $100; the IRS publishes each year's inflation \
adjustments late in the year before (CONVENTION: the usual timing, not a \
cited date), so look for the 2027 figure from November 2026.",
};
/// Every dated rule the engine applies.
pub const DATED_RULES: &[DatedRule] = &[THRESHOLD_1099];
/// The dated rules due for review on `today`.
pub fn stale_rules(today: NaiveDate) -> Vec<&'static DatedRule> {
DATED_RULES.iter().filter(|r| r.is_stale(today)).collect()
}
+63 -5
View File
@@ -86,7 +86,7 @@ fn books() -> Ledger {
#[test]
fn cash_paid_per_vendor_and_who_is_reportable() {
let l = books();
let r = vendor_1099_report(&l, 2026, None);
let r = vendor_1099_report(&l, 2026, None).unwrap();
assert_eq!(r.threshold, dec("2000"));
let rows: Vec<(&str, Decimal, bool)> = r
.vendors
@@ -110,14 +110,15 @@ fn cash_paid_per_vendor_and_who_is_reportable() {
#[test]
fn years_and_thresholds() {
let l = books();
assert_eq!(default_threshold(2025), dec("600"));
assert_eq!(default_threshold(2026), dec("2000"));
let y2025 = vendor_1099_report(&l, 2025, None);
// SOURCE: rules.md B-19 (sources/irs-1099-thresholds.md).
assert_eq!(default_threshold(2025), Ok(dec("600")));
assert_eq!(default_threshold(2026), Ok(dec("2000")));
let y2025 = vendor_1099_report(&l, 2025, None).unwrap();
let dana = y2025.vendors.iter().find(|v| v.name == "Dana").unwrap();
assert_eq!(dana.paid, dec("300"));
assert!(!dana.reportable, "300 < 600");
// An override: at 1,000, Ace's 1,200 is reportable.
let r = vendor_1099_report(&l, 2026, Some(dec("1000")));
let r = vendor_1099_report(&l, 2026, Some(dec("1000"))).unwrap();
assert!(
r.vendors
.iter()
@@ -145,3 +146,60 @@ fn tags_need_real_entries_and_vendors() {
assert_eq!(Form1099::parse("misc"), Some(Form1099::Misc));
assert_eq!(Form1099::parse("K"), None);
}
/// Spec (Stephen, 2026-10-13): a rule set by law that wanderbooks doesn't
/// know for a year is never guessed. 26 U.S.C. §6041(h) indexes the $2,000
/// threshold for calendar years after 2026; until the indexed amount is in
/// the rule table, asking for it is an error that says what to do.
#[test]
fn an_unknown_year_threshold_is_never_guessed() {
let err = default_threshold(2027).unwrap_err();
let LedgerError::RuleNotKnown {
rule,
year,
known_through,
spec,
} = &err
else {
panic!("expected RuleNotKnown, got {err:?}");
};
assert_eq!(
(rule.as_str(), *year, *known_through),
("1099 reporting threshold", 2027, 2026)
);
assert!(spec.ends_with("irs-1099-thresholds.md"), "{spec}");
let why = err.to_string();
for needle in [
"2027",
"official source",
"CPA",
"github.com/swaits/wanderbooks",
] {
assert!(why.contains(needle), "missing {needle:?} in: {why}");
}
let l = books();
assert!(matches!(
vendor_1099_report(&l, 2027, None),
Err(LedgerError::RuleNotKnown { .. })
));
// An explicit threshold (from the IRS, or the owner or CPA) works.
let r = vendor_1099_report(&l, 2027, Some(dec("2100"))).unwrap();
assert_eq!(r.threshold, dec("2100"));
}
/// Spec: a dated rule is due for review from its review date, and stays
/// due; before it, nothing is stale. The engine has no clock: callers pass
/// today.
#[test]
fn dated_rules_go_stale_on_their_review_date() {
use ledger_core::rules::{THRESHOLD_1099, stale_rules};
assert!(stale_rules(d(2026, 10, 13)).is_empty());
let due = THRESHOLD_1099.review_after();
assert!(stale_rules(due.pred_opt().unwrap()).is_empty());
assert_eq!(stale_rules(due).len(), 1);
assert_eq!(
stale_rules(d(2027, 1, 1))[0].name,
"1099 reporting threshold"
);
assert_eq!(stale_rules(d(2030, 6, 1)).len(), 1);
}