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

The owner (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 8559bba6bc
commit 4eaac20311
15 files changed
+349 -47

No files matched your search

+24 -2
View File
@@ -1,7 +1,7 @@
//! What the books need: drafts left unposted, missing receipts, schedules
//! and recurring entries due, open past periods, uncategorized tax lines,
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
//! owners. Each item says what's wrong, where to look, and the prompt that
//! owners, and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that
//! asks the agent to fix it (the GUI never changes the books itself).
use crate::books::{Attention, Books, Severity};
@@ -176,12 +176,34 @@ pub fn find(b: &Books) -> Vec<Attention> {
}
}
// Rules set by law that wanderbooks may no longer know (rules.md;
// owner, 2026-10-13): research or ask, then update them for everyone.
for rule in ledger_core::rules::stale_rules(today) {
out.push(Attention {
severity: Severity::Todo,
title: format!("Check the {}", rule.name),
detail: format!(
"wanderbooks knows it through {}. {}",
rule.known_through, rule.review_why
),
target: None,
prompt: format!(
"wanderbooks' {} is known only through {}. Find the current figure from the official source (see {} in the wanderbooks repo), or ask me or our CPA, and tell me what you found. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official source (or at least an issue).",
rule.name, rule.known_through, rule.source
),
});
}
let mut years = vec![today.year()];
if today.month() == 1 {
years.insert(0, today.year() - 1);
}
for year in years {
let r = vendor_1099_report(&b.ledger, year, None);
// A year whose threshold isn't known is flagged by the stale-rule
// item above; never guess it here.
let Ok(r) = vendor_1099_report(&b.ledger, year, None) else {
continue;
};
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
out.push(Attention {
severity: Severity::Problem,
+2 -2
View File
@@ -413,7 +413,7 @@ fn recurring(ui: &mut Ui, b: &Books) {
fn parties(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let year = chrono::Datelike::year(&b.today);
let r1099 = vendor_1099_report(&b.ledger, year, None);
let r1099 = vendor_1099_report(&b.ledger, year, None).ok();
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
@@ -427,8 +427,8 @@ fn parties(ui: &mut Ui, b: &Books) {
}
for v in vendors {
let paid = r1099
.vendors
.iter()
.flat_map(|r| &r.vendors)
.find(|x| x.vendor_id == v.id)
.map(|x| x.paid)
.unwrap_or_default();
+9 -1
View File
@@ -1777,7 +1777,15 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let r = vendor_1099_report(&b.ledger, year, None);
let r = match vendor_1099_report(&b.ledger, year, None) {
Ok(r) => r,
Err(e) => {
// Never guess a threshold (rules.md B-19): say why there's no
// worksheet, in the engine's words.
ui.label(RichText::new(e.to_string()).color(p.muted));
return;
}
};
ui.label(
RichText::new(format!(
"Calendar {year}. Threshold {} ($600 through 2025; $2,000 from 2026, indexed after). Counts cash paid on entries tagged with the vendor; card payments are reported by the card processor (1099-K) and drop out.",