feat(drafts): the drafts tools, the preview digest on every confirm, the digest tool (P4)
Accounting reason: a confirm must act on the thing that was previewed. Every tool with confirmed (post_entry, reverse_entry, run_schedules, dispose_asset, reconcile, close_period, create_invoice, create_bill, write_off_invoice, purge_tin, consolidate_duplicates, and the S6 settlement tools receive_payment and pay_bill, which gain confirmed and preview on a clone of the ledger) returns preview (SHA-256 of the canonical preview) and refuses a confirm whose preview is missing or stale (stale_preview, before anything is written). A draft's digest is its own state and the settings seq; an operation's is the tool, its arguments as sent, the settings seq and the journal head seq, so another session's write forces a re-preview (D-50). New tools books.list_drafts, books.set_draft_state, books.discard_draft, books.preview_entry; reports carry as_of_status (drafts open on or before their end, unreconciled accounts, settings problems) and take strict; the CPA package is refused while a draft dated in it is open unless allow_open_drafts gives a reason, journaled as a decision; books.digest takes since_seq (what was posted since a journal line, backdated entries included: defect 6) and lists zero_net_accounts, drafts_by_state and the reconciliation split. Needs attention gains 'draft awaiting receipt' beside 'draft not posted'; the drafts handbook is rewritten. Every over-the-wire harness confirms the way an agent does. Docs: mcp-interface, rules B-43, parity, testing (with the For-P12 paragraph), architecture, and the unit's As-built block. Tests: drafts.rs (a_draft_waits_for_its_receipt_and_posts, the_gate_moved_and_the_mcp_error_is_unchanged, a_reversal_needs_no_receipt_of_its_own, discard_unblocks_the_close_over_the_wire, report_warns_on_draft_before_as_of, cpa_package_refuses_open_draft, stale_preview_refused, every_confirm_tool_takes_a_preview, digest_since_seq_is_posting_time, legacy_reversed_replays_identically, gui_and_server_gate_agree), attention_parity.rs, format_upgrade.rs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
7299cf90f2
commit
6a66a2855b
49 files changed
+2526
-202
No files matched your search
@@ -135,6 +135,16 @@ extractions have a table of their own. The viewer's Documents page groups
|
||||
by category and filters by subject; Needs attention reads both through
|
||||
`PackageBooks`.
|
||||
|
||||
**The evidence gate (P4, I-5).** `ledger_core::evidence::requirement`
|
||||
(with `Policy`, the settings' `policy.evidence.*` read in the core) is the
|
||||
one decision whether an entry needs a receipt; `Store::post_entry_checked`,
|
||||
the server's previews and digest, the viewer and Needs attention all call
|
||||
it, and `ReceiptPolicy` in `ledger-config` keeps only the words. A draft
|
||||
has a state (`DraftState`, `entry.draft_state_set`) and can be discarded
|
||||
(`entry.draft_discarded`); `Ledger::posted_seq` is the journal line that
|
||||
posted each entry. Every MCP tool with `confirmed` takes the digest
|
||||
`Preview` (`tools/helpers.rs`) its preview returned.
|
||||
|
||||
## The journal format gate (`ledger-store`, P0)
|
||||
|
||||
Every journal line passes a gate after its parse, seq and chain checks
|
||||
|
||||
+109
-25
@@ -354,7 +354,7 @@ Tool descriptions are at most 700 characters; eight longer ones are listed
|
||||
in `tool_descriptions_fit_700_characters`, to be shortened in P5.
|
||||
|
||||
**Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool
|
||||
that changes anything also has a `destructiveHint`, true for these 30:
|
||||
that changes anything also has a `destructiveHint`, true for these 31:
|
||||
`books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`,
|
||||
`books.unlink_attachment`, `books.withdraw_attachment`,
|
||||
`books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`,
|
||||
@@ -366,7 +366,7 @@ that changes anything also has a `destructiveHint`, true for these 30:
|
||||
`books.create_invoice`, `books.create_bill`, `books.receive_payment`,
|
||||
`books.pay_bill`, `books.void_document`, `books.write_off_invoice`,
|
||||
`books.run_payroll`, `books.remit_payroll_taxes`,
|
||||
`books.consolidate_duplicates`; and an
|
||||
`books.consolidate_duplicates`, `books.discard_draft`; and an
|
||||
`idempotentHint` on the ten that a repeat with the same arguments leaves
|
||||
the journal and the vault as they were: `books.verify`,
|
||||
`books.run_depreciation`, `books.run_schedules`, `books.run_recurring`,
|
||||
@@ -428,6 +428,24 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
|
||||
+ resulting account balances) and post nothing. The agent must present the
|
||||
preview to the human before confirming. This encodes the standing
|
||||
fact-check rule in the protocol itself.
|
||||
- **The preview digest (P4, D-50).** Every tool called without
|
||||
`confirmed` returns `preview`, the SHA-256 of what it shows: for a draft
|
||||
entry, its canonical state (lines, classes, party, vendor, instrument,
|
||||
the evidence linked) and the settings seq; for any other operation, the
|
||||
tool, its arguments, the settings seq and the journal head seq. Every
|
||||
call with `confirmed: true` takes that `preview` and is refused, before
|
||||
anything is written, with the guard `{ code: "stale_preview", message,
|
||||
remedy_tool }` when it is missing or no longer matches: an argument
|
||||
changed, or another session wrote in between (a post by another session
|
||||
forces a re-preview, by design). No key is involved: it detects drift;
|
||||
it does not prove a human looked. The tools that take `preview` are all
|
||||
those with `confirmed`: `books.post_entry`, `books.reverse_entry`,
|
||||
`books.run_schedules`, `books.dispose_asset`, `books.reconcile`,
|
||||
`books.close_period`, `books.create_invoice`, `books.create_bill`,
|
||||
`books.receive_payment`, `books.pay_bill`, `books.write_off_invoice`,
|
||||
`books.purge_tin`, `books.consolidate_duplicates` and
|
||||
`books.discard_draft`. (`books.void_document`, `books.run_depreciation`
|
||||
and `books.run_recurring` post at once and have no `confirmed`.)
|
||||
- **Errors are typed strings** mirroring `LedgerError` variants, with the
|
||||
offending values included (e.g. unbalanced totals).
|
||||
|
||||
@@ -460,7 +478,7 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
|
||||
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
|
||||
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
|
||||
class, bad amount) adds nothing.
|
||||
- `books.post_entry { entry_id, confirmed?, expected_total? }` → `{ status, entry_id, date,
|
||||
- `books.post_entry { entry_id, confirmed?, preview?, expected_total? }` → `{ status, preview, entry_id, date,
|
||||
description, total, lines (with resulting balances), receipts,
|
||||
receipt_required, receipt_missing, split_detected, party_policy,
|
||||
party_id?, party?, parties, parties_error?, no_party_reason?,
|
||||
@@ -473,7 +491,7 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
|
||||
share: "amount" | "percent" | "derived", amount?, percent?, resolved }`);
|
||||
when the shares don't add up to what the entry moved, `parties_error`
|
||||
says why and confirming fails with code `party_shares`.
|
||||
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
||||
- `books.reverse_entry { entry_id, date, description, confirmed, preview? }` → same
|
||||
preview/confirm pattern as post. A reversed depreciation run or schedule
|
||||
month is un-posted (its month is due again); refused, at the preview
|
||||
already (P3): an asset's disposal entry (it would stay disposed), a
|
||||
@@ -578,10 +596,23 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
|
||||
one; no registered asset's account as cash).
|
||||
- Changed in P3 (breaking): `net_income`, `depreciation` and
|
||||
`working_capital` are gone (net income is in `operating_reconciliation`).
|
||||
- `books.digest { since: "2026-10-01" }`
|
||||
→ `{ since, entries: [...], totals_by_account: [...], stale_rules: [...],
|
||||
- `books.digest { since: "2026-10-01" | since_seq: 412, as_of? }`
|
||||
→ `{ since, since_seq?, head_seq, entries: [...], totals_by_account: [...],
|
||||
zero_net_accounts: [...], drafts_by_state: { building?, awaiting_evidence? },
|
||||
reconciliation_tracked: [...], never_reconciled: [...], stale_rules: [...],
|
||||
journal_repairs: [...], settings_problems: [...], unreconciled_accounts:
|
||||
[...], uncategorized_accounts: [...] }`.
|
||||
[...], uncategorized_accounts: [...] }`. `since` selects entries by their
|
||||
date; `since_seq` selects the entries posted after a journal line (the
|
||||
`head_seq` of an earlier answer), backdated entries included, which a
|
||||
date would miss (P4, defect 6). `as_of` (default today) bounds the
|
||||
movement. An account the entries touched whose net movement is zero is
|
||||
listed under `zero_net_accounts` rather than dropped silently.
|
||||
`drafts_by_state` counts the open drafts; `reconciliation_tracked` and
|
||||
`never_reconciled` split the cash and card accounts (`books.cash_accounts`)
|
||||
by whether a statement was ever recorded for them. Each entry's
|
||||
`receipt_required` is the engine's one decision
|
||||
(`ledger_core::evidence::requirement`): a document's entry, a close, a
|
||||
depreciation run and a reversal need none of their own.
|
||||
The human review surface: "what did the AI do with my books lately." The
|
||||
digest is the answer to "trust, but verify." `stale_rules` lists legal
|
||||
amounts wanderbooks may no longer know as of today (`{ name,
|
||||
@@ -724,7 +755,7 @@ life's last day is 9999-12-31 at the latest.
|
||||
run-all's assets not yet in service and any whose schedule can't be
|
||||
computed. A run before the in-service date is refused.
|
||||
- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account,
|
||||
gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` (the
|
||||
gain_loss_account, buyer?, confirmed?, preview?, receipt_attachment_ids? }` (the
|
||||
date never before the acquired date) →
|
||||
without confirmation: `{ status: "preview", asset_id, entry_id (0 in a
|
||||
preview), accumulated, book_value,
|
||||
@@ -808,7 +839,7 @@ through the policy.
|
||||
compute: then it says why, `remaining` and `next` are null and `months`
|
||||
empty, and `books.run_schedules` refuses until it is dealt with.
|
||||
- `books.list_schedules {}` → `{ schedules: [...] }`, same shape.
|
||||
- `books.run_schedules { through?, confirmed? }` → `{ status: "preview" |
|
||||
- `books.run_schedules { through?, confirmed?, preview? }` → `{ status: "preview" |
|
||||
"posted", postings: [{ schedule_id, date, description, amount,
|
||||
entry_id? }], skipped: [{ schedule_id, date, reason }] }`: every
|
||||
unposted month ending on or before `through` (default today).
|
||||
@@ -1482,7 +1513,7 @@ and the CPA package carries them.
|
||||
of a binary file (a W-9 PDF re-saved or re-scanned), and a TIN the vault
|
||||
doesn't hold written another way, are not detected: P7 takes full
|
||||
TINs out of the agent's reach.
|
||||
- `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" |
|
||||
- `books.purge_tin { party, reason, confirmed?, preview? }` → `{ status: "preview" |
|
||||
"purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?,
|
||||
journaled, note }`. Without `confirmed`, a preview that changes nothing.
|
||||
Confirmed, deletes the party's vault records (needs no key) and, when the
|
||||
@@ -1667,7 +1698,7 @@ checks the text blob's presence and hash.
|
||||
none may be the paying entry itself or be named twice. It changes no
|
||||
amount. "The member was paid back" is read from this record only, never
|
||||
guessed (IZ-18); Needs attention asks until it is recorded.
|
||||
- `books.consolidate_duplicates { confirmed? }` → `{ status: "preview" |
|
||||
- `books.consolidate_duplicates { confirmed?, preview? }` → `{ status: "preview" |
|
||||
"consolidated", groups: [{ sha256, keep, withdraw, links }], withdrawn
|
||||
}`. For every set of attachments holding the same bytes it keeps the
|
||||
oldest (`keep`), links it to every subject the others had, unlinks the
|
||||
@@ -1707,10 +1738,18 @@ posting.
|
||||
violation, and the append-only audit log would show it anyway.
|
||||
(`asset_id` instead of `entry_id` for a disposal; `split_group: [ids]`
|
||||
when the split check triggered.)
|
||||
- **Not checked yet:** `books.receive_payment`, `books.pay_bill`,
|
||||
`books.run_payroll` and `books.remit_payroll_taxes` post at once with no
|
||||
receipt check: attach their evidence right after, to the entry they
|
||||
return. (`books.create_invoice` and `books.create_bill` preview and
|
||||
- **One gate (P4, I-5, D-47).** `ledger_core::evidence::requirement`
|
||||
decides whether an entry needs evidence; the store's gate
|
||||
(`Store::post_entry_checked`), the previews, the digest, the viewer and
|
||||
Needs attention all call it. A reversal needs no receipt of its own (its
|
||||
evidence is its original's, D-12); neither does a closing entry, a
|
||||
schedule month's or a depreciation run's posting, an asset's disposal,
|
||||
a document's entry or a payroll run or remittance. A refusal is the
|
||||
`receipt_required` error above, unchanged.
|
||||
- **Not checked:** `books.receive_payment`, `books.pay_bill`,
|
||||
`books.run_payroll` and `books.remit_payroll_taxes` are exempt from the
|
||||
receipt policy by origin (above): attach their evidence right after, to
|
||||
the entry they return. (`books.create_invoice` and `books.create_bill` preview and
|
||||
confirm, and a bill's vendor invoice is required, in books that have
|
||||
declared `documents_v2`.)
|
||||
- **Anti-splitting.** Posting an entry triggers a split check: posted
|
||||
@@ -1971,7 +2010,7 @@ accounting only: the caller supplies every figure), tax filing.
|
||||
|
||||
- `books.create_period { name, start, end }` → `{ period_id }`.
|
||||
Dates are YYYY-MM-DD, inclusive.
|
||||
- `books.close_period { period_id, confirmed? }` → without confirmation:
|
||||
- `books.close_period { period_id, confirmed?, preview? }` → without confirmation:
|
||||
`{ status: "preview", period_id, closing_entry: null, lines: [...],
|
||||
members }`: the exact closing entry (the close run on a copy of the
|
||||
ledger), posting nothing; with `confirmed: true`: `{ status: "closed",
|
||||
@@ -2030,7 +2069,7 @@ accounting only: the caller supplies every figure), tax filing.
|
||||
the same attachment can record it again. Refused while a reconciliation
|
||||
not voided rests on it.
|
||||
- `books.reconcile { statement_attachment? | account, statement_date,
|
||||
statement_balance, confirmed? }` → `{ status: "preview" | "reconciled",
|
||||
statement_balance, confirmed?, preview? }` → `{ status: "preview" | "reconciled",
|
||||
account_code, statement_date, statement_attachment?, book_balance,
|
||||
cleared_balance, statement_balance, difference, uncleared_lines,
|
||||
cleared_lines, problems, reconciliation? }`, each line `{ entry_id,
|
||||
@@ -2069,7 +2108,7 @@ snapshots; see plan 8.2 S4.
|
||||
lines: [{ description, amount, quantity?, unit_price?, account?,
|
||||
class? }], customer_ref?, memo?, bill_to?: { name?, address?, email?,
|
||||
phone?, contact?, billing_address?, shipping_address?, billing_email? },
|
||||
confirmed? }` → without `confirmed`: `{ preview: true, number?, total,
|
||||
confirmed?, preview? }` → without `confirmed`: `{ preview, number?, total,
|
||||
due_date, terms?, lines: [{ account, name, side, amount, class? }],
|
||||
pdf }`, and nothing is written; with `confirmed: true`: `{ id, number?,
|
||||
total, due_date, terms?, entry_id, pdf: { attachment_id, sha256 },
|
||||
@@ -2124,7 +2163,7 @@ snapshots; see plan 8.2 S4.
|
||||
- `books.create_bill { vendor | party_id, date, received_on?, vendor_ref?,
|
||||
terms? | due_date?, lines: [{ description, amount, account, class? }],
|
||||
memo?, upload_id | existing_attachment_id, duplicate_reason?,
|
||||
confirmed? }` → without `confirmed`: `{ preview: true, total, due_date,
|
||||
confirmed?, preview? }` → without `confirmed`: `{ preview, total, due_date,
|
||||
terms?, lines: [...], discount_by?, max_discount? }` and nothing is
|
||||
written; with `confirmed: true`: `{ id, total, due_date, terms?,
|
||||
entry_id, lines?, discount_by?, max_discount?, file: { attachment_id,
|
||||
@@ -2158,9 +2197,12 @@ snapshots; see plan 8.2 S4.
|
||||
- `books.receive_payment { date, deposit_to | cash_account, instrument?,
|
||||
reference?, invoice | document_id, amount? | applications: [{ invoice,
|
||||
amount, discount?, discount_reason? }], fee?: { amount, account?,
|
||||
vendor? }, paid_in_full_marking? }` → `{ entry_id, documents: [{ ref,
|
||||
number?, outstanding, status }], deposited, remaining?, status?,
|
||||
warnings? }`. Settles one or several invoices of one customer in one
|
||||
vendor? }, paid_in_full_marking?, confirmed?, preview? }` → `{ preview?,
|
||||
entry_id, documents: [{ ref, number?, outstanding, status }], deposited,
|
||||
remaining?, status?, warnings? }`. Without `confirmed: true` a preview
|
||||
(entry 0, the figures the documents would show, `preview` to pass
|
||||
back) and nothing is written (P4: the S6 tools preview and confirm like
|
||||
the create tools). Settles one or several invoices of one customer in one
|
||||
entry (B-42): Dr the deposit account the cash less the fee, Dr the
|
||||
payment-processing-fees account the fee, Dr sales allowances the
|
||||
discounts, Cr A/R each invoice's cash and discount, every receivable
|
||||
@@ -2180,7 +2222,7 @@ snapshots; see plan 8.2 S4.
|
||||
payments; they never collect them (D-45).
|
||||
- `books.pay_bill { date, paid_from | cash_account, instrument?,
|
||||
reference?, bill | document_id, amount? | applications: [{ bill, amount,
|
||||
discount?, discount_reason? }] }` → as `receive_payment`, per bill and
|
||||
discount?, discount_reason? }], confirmed?, preview? }` → as `receive_payment`, per bill and
|
||||
one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer
|
||||
account the cash, Cr purchase discounts the discounts. A discount on a
|
||||
bill with an asset or prepaid line is refused with its remedy (record
|
||||
@@ -2208,7 +2250,7 @@ snapshots; see plan 8.2 S4.
|
||||
(`format_upgrade_pending`). `sends` is every send on the invoice, in
|
||||
order, the new one last.
|
||||
- `books.write_off_invoice { invoice, date, amount?, reason, upload_id |
|
||||
existing_attachment_id, confirmed? }` → `{ entry_id, document: { ref,
|
||||
existing_attachment_id, confirmed?, preview? }` → `{ entry_id, document: { ref,
|
||||
status, paid, written_off, outstanding } }`. Writes off an invoice that
|
||||
will not be collected (a bad debt, P4b S7; rules.md B-41): posts Dr
|
||||
`6990` Bad Debt Expense / Cr `1200` Accounts Receivable, the receivable
|
||||
@@ -2425,10 +2467,15 @@ until the owner upgrades them.
|
||||
## Tools — CPA handoff
|
||||
|
||||
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
|
||||
investing_accounts?, include_receipts?, include_tins? }` → `{ file,
|
||||
investing_accounts?, include_receipts?, include_tins?,
|
||||
allow_open_drafts?: { reason } }` → `{ file,
|
||||
tins_included, tin_gaps, warnings?, note }`: the zip,
|
||||
`cpa-package-<as_of>.zip`, handed over as `file` (a `HandedFile`,
|
||||
"Tools — the TIN vault").
|
||||
**Open drafts** (P4, plan 4.8): refused with `open_drafts` while a draft
|
||||
dated on or before `as_of` is open (post it or discard it); with
|
||||
`allow_open_drafts: { reason }` the package goes and the reason is
|
||||
journaled as a decision.
|
||||
**As of one day** (`docs/spec/rules.md` B-33): nothing in it reflects
|
||||
anything dated after `as_of` (default today): no figure, entry,
|
||||
receipt or worksheet row; the chart and `documents/` are as they stand.
|
||||
@@ -2520,3 +2567,40 @@ until the owner upgrades them.
|
||||
count and what each file is). Period reports cover the fiscal year
|
||||
holding `as_of`, through it.
|
||||
What the CPA needs; they never log into the system.
|
||||
|
||||
## Tools — drafts (P4)
|
||||
|
||||
A draft is an entry being built (`books.draft_entry`, `books.add_line`) that
|
||||
is not in any report until it posts. P4 gives it a state, a way out, and a
|
||||
read-only preview (rules.md B-43; the journal feature `drafts_v2`: the
|
||||
state and discard tools wait for the books' format upgrade).
|
||||
|
||||
- `books.list_drafts { state?, as_of? }` → `{ drafts: [{ entry_id, date,
|
||||
description, total, state, note, missing_evidence }] }`. `state` is
|
||||
`building` or `awaiting_evidence`; `as_of` keeps drafts dated on or
|
||||
before a day. `missing_evidence` is true when the receipt policy covers
|
||||
the draft and it has no receipt yet.
|
||||
- `books.set_draft_state { entry_id, state, note? }` → `{ entry_id, state,
|
||||
note }`. `awaiting_evidence` with a note ("Figma invoice due 11/3") says
|
||||
the draft is finished and waits for its document; `building` says it is
|
||||
being worked on. A state is a note: no line or figure changes, and the
|
||||
receipt policy is still checked when the draft posts. Only a draft has
|
||||
one.
|
||||
- `books.discard_draft { entry_id, reason, confirmed?, preview? }` →
|
||||
`{ status: "preview" | "discarded", entry_id, description, total,
|
||||
holds_open?, preview }`. Throws away a draft that will never post; only a
|
||||
draft (a posted entry is reversed, never removed). Nothing in any report
|
||||
moves, the id is not reused, and a period the draft held open can close
|
||||
(`holds_open` names those). Previewed first, then confirmed with the
|
||||
`preview` digest.
|
||||
- `books.preview_entry { entry_id }` → the `books.post_entry` preview
|
||||
(`status: "preview"`, lines, receipts, `receipt_required`,
|
||||
`receipt_missing`, party and approval checks, `preview`) as a read-only
|
||||
call; pass its `preview` to `books.post_entry` with `confirmed: true`.
|
||||
|
||||
**What a report leaves open.** `books.trial_balance`, `books.profit_and_loss`,
|
||||
`books.balance_sheet`, `books.cash_flow` and
|
||||
`books.profit_and_loss_by_class` return `as_of_status: {
|
||||
drafts_open_on_or_before: [draft], unreconciled: [...], settings_problems:
|
||||
[...] }` for the report's end date, and take `strict: true` to be refused,
|
||||
naming what is open, instead of warned.
|
||||
@@ -207,3 +207,19 @@ what a payment settles (`entry.settles`, the only source of "reimbursed"),
|
||||
and gives company records four more homes (`notices`, `correspondence`,
|
||||
`plans`, `history`, D-20). Rules.md B-44 states it; `docs/testing.md`'s P6
|
||||
section lists its tests and the P12 mutation targets.
|
||||
|
||||
### P4: drafts, the evidence gate, the preview digest (2026-10-29)
|
||||
|
||||
Three new rows, none above changed status, and the floor stands.
|
||||
|
||||
| Area | Counterpart (a cross-check, not the spec) | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| Draft states and discard (`books.set_draft_state`, `books.discard_draft`, `books.list_drafts`; Lean `Step.discard`) | Draft/pending transactions (QBO "pending"), voiding an unposted entry | partial | `entry.draft_state_set` and `entry.draft_discarded` under `drafts_v2`; B-43; tests `discard_unblocks_close`, `draft_states_replay_and_project`, `a_draft_waits_for_its_receipt_and_posts`; the fixture `p4_before` was written by the version before and opens to the same figures; `partial` until a hand-verified golden covers a discard |
|
||||
| One evidence gate (`evidence::requirement`) | Attach-required rules on posting | partial | the store, the previews, the digest, the viewer and Needs attention call one function; `gui_and_server_gate_agree`, `a_reversal_needs_no_receipt_of_its_own` (D-47); same tests as above for the golden |
|
||||
| Preview digest on every confirm (D-50) | Confirm dialogs | partial | `stale_preview_refused`, `every_confirm_tool_takes_a_preview`; detects drift, not a human's look |
|
||||
|
||||
The digest also gained `since_seq` (what was posted since a journal line,
|
||||
backdated entries included: defect 6), `zero_net_accounts`,
|
||||
`drafts_by_state` and the reconciliation split; reports gained
|
||||
`as_of_status` and `strict`; the CPA package is refused while a draft
|
||||
dated in it is open (4.8).
|
||||
@@ -3431,6 +3431,77 @@ check_discount}`, `arap::{Application, Fee, SettleParams, SettlementRow}`,
|
||||
The Lean step is `Documents.Step.document` with every control line tagged
|
||||
(`Documents.settle_unattached`); no new `Step` constructor.
|
||||
|
||||
## B-43 A draft waits on its evidence, and one that will never post can be discarded
|
||||
|
||||
**bookspec:** `BS-entry-5`
|
||||
|
||||
**Status:** AUTHORITY (the evidence a draft waits for, Pub. 583 and Rev.
|
||||
Proc. 98-25); DECISION D-47 (owner-adopted recommendation, 2026-10-26:
|
||||
reversals stay posted and need no evidence of their own); CONVENTION (the
|
||||
two states, and discarding a draft rather than posting and reversing it).
|
||||
|
||||
```text
|
||||
GOAL: no entry the receipt policy covers posts without the document that
|
||||
supports it, decided in one place; a draft that is finished and
|
||||
waits for that document says so; a draft that will never post can
|
||||
be thrown away, so it stops holding its period open.
|
||||
SOURCE: IRS Pub. 583 (docs/spec/sources/irs-records.md: "you must keep
|
||||
supporting documents ... they support the entries in your
|
||||
books"); Rev. Proc. 98-25 §5.01 (the records must let the books be
|
||||
demonstrated); DECISION D-47 (a reversal's evidence is its
|
||||
original's, D-12); CONVENTION (a draft's state is a note: it moves
|
||||
no figure).
|
||||
INVARIANTS:
|
||||
1. One function, `evidence::requirement`, decides whether an entry needs
|
||||
evidence: `Required` (with why) or `Exempt` (with the source of the
|
||||
exemption). The store's gate, the server's previews and digest, the
|
||||
viewer and Needs attention all call it; none decides on its own.
|
||||
2. Exempt by origin, each with its source: a period's closing entry
|
||||
(derived from the books), a schedule month's or a depreciation run's
|
||||
posting (the schedule is the evidence), an asset's disposal, an entry
|
||||
a document or a payroll command posts (it carries its own evidence
|
||||
or is posted at once by design: an invoice, a bill, a payment, a
|
||||
write-off, a payroll run or its tax remittance), and a reversal (its
|
||||
evidence is its original's, D-47, D-12). Every other entry, hand
|
||||
entries and recurring drafts included, is covered by the policy
|
||||
settings (`policy.evidence.*`): required above the threshold, on a
|
||||
required account, or as part of a split group; exempt only when every
|
||||
line is on an exempt account.
|
||||
3. A draft has a state: `building` (the default) or `awaiting_evidence`
|
||||
with a note. A state is a note on the draft; it moves no line, no
|
||||
balance and no report, and a posted entry has none.
|
||||
4. A draft may be discarded, with a reason, and only a draft: a posted
|
||||
entry is never removed (B-8). Discarding moves no balance and spends
|
||||
no id, and what was recorded about the draft (its vendor, instrument
|
||||
and parties) goes with it; the journal still holds its lines and why
|
||||
it went. A discarded draft no longer holds a period open.
|
||||
5. Evidence is checked when the entry posts, not when it is drafted: a
|
||||
draft awaiting evidence posts the moment the evidence is attached
|
||||
and the post is confirmed.
|
||||
6. Every refusal leaves the journal unchanged.
|
||||
SCENARIOS:
|
||||
1. A hand entry of 40.00 under a 25.00 threshold, no receipt: the post
|
||||
is refused with `receipt_required`; the draft may be set
|
||||
`awaiting_evidence` with the note "Figma invoice due".
|
||||
2. The receipt is attached; the post succeeds; the entry has no state.
|
||||
3. A reversal of that entry: it posts with no receipt of its own.
|
||||
4. A draft dated in January, January closing: the close is refused
|
||||
while the draft stands; discarded, the close succeeds.
|
||||
5. A posted entry: set-state and discard are both refused.
|
||||
```
|
||||
|
||||
**Enforced:** `ledger_core::evidence::{requirement, Requirement}`,
|
||||
`Ledger::{set_draft_state, discard_draft, posted_seq}`,
|
||||
`Store::{set_draft_state, discard_draft, post_entry_checked}` (the gate the server posts a hand draft by; `Store::post_entry` stays the primitive commands and fixtures post through); events
|
||||
`entry.draft_state_set`, `entry.draft_discarded` (feature `drafts_v2`).
|
||||
**Checked:** Lean `Step.discard`, `Step.discard_keeps_books`,
|
||||
`Step.discard_unblocks_close`; `ledger-core` `tests/invariants.rs`
|
||||
(`discard_unblocks_close`); `ledger-store` `tests/store.rs`
|
||||
(`draft_states_replay_and_project`, `drafts_v2_waits_for_the_upgrade`,
|
||||
`posted_seq_is_the_posting_lines_seq`); `ledger-cli` `tests/drafts.rs` (`a_draft_waits_for_its_receipt_and_posts`,
|
||||
`a_reversal_needs_no_receipt_of_its_own`, `gui_and_server_gate_agree`,
|
||||
`stale_preview_refused`).
|
||||
|
||||
## B-44 Evidence is read from the bytes, never trusted over them; identical files are kept once
|
||||
|
||||
**bookspec:** `BS-ledger-12`
|
||||
|
||||
@@ -3594,3 +3594,78 @@ token" must die on `an_unreadable_token_file_is_an_error_never_no_token`),
|
||||
`Keeper::delete`'s `NotFound` arm, and `Account::key`'s three parts
|
||||
(`an_account_names_the_root_by_hash_and_the_book_and_token`);
|
||||
`bridge.rs::token_file_refusal`'s `holds_token` guard.
|
||||
|
||||
## P4: draft states, the evidence gate, the preview digest (2026-10-29)
|
||||
|
||||
A draft has a state (`building`, or `awaiting_evidence` with a note) and a
|
||||
way out (`books.discard_draft`); one function, `evidence::requirement`,
|
||||
decides whether an entry needs evidence; every tool with `confirmed` takes
|
||||
the `preview` digest it returned; `books.digest` can be asked "since a
|
||||
journal line". Journal feature `drafts_v2`; events `entry.draft_state_set`
|
||||
and `entry.draft_discarded`; schema 19 (taken as the next after the last,
|
||||
renumbered at merge): `entries.posted_seq` and the `draft_states` table
|
||||
(drafts are not rows of `entries`, so the plan's `entries.draft_state`
|
||||
column became a table).
|
||||
|
||||
- **`ledger-core` `tests/invariants.rs`:** `discard_unblocks_close` (a
|
||||
period with an open draft can't close, a state changes no figure,
|
||||
discarded it closes; balances, order and ids as they were; a posted
|
||||
entry is never discarded or given a state).
|
||||
- **Lean:** `Step.discard` (preservation of `Inv`, including
|
||||
`draftsOpen`), `Step.discard_keeps_books`, `Step.discard_drafts`,
|
||||
`Step.discard_unblocks_close`, in `Audit.lean`.
|
||||
- **`ledger-store` `tests/store.rs`:** `draft_states_replay_and_project`
|
||||
(both events journaled as written, `draft_states` holds only the waiting
|
||||
draft and loses it when it posts, live equals replay, the refusals),
|
||||
`drafts_v2_waits_for_the_upgrade`, `posted_seq_is_the_posting_lines_seq`
|
||||
(hand, backdated, reversal and closing entries: the number is the line
|
||||
that posted them, live, replayed and projected);
|
||||
`books_written_under_older_rules_still_open` hand-writes both events.
|
||||
`tests/format.rs`: `an_older_version_stops_at_the_draft_events`;
|
||||
`format.rs`'s `drafts_v2_is_its_own_feature`.
|
||||
- **8.0.6:** the fixture `old-books/p4_before` (writer
|
||||
`old-books-writer/src/bin/p4_before.rs`, run against 25b52580) holds
|
||||
hand entries posted late, a reversal, a document and its payment, a
|
||||
schedule month, a recurring draft, two open drafts and a closed month;
|
||||
`every_book_an_earlier_version_wrote_upgrades_and_still_verifies` opens
|
||||
it to the writer's figures, so no figure moved (serialized entries omit
|
||||
a building state). `books_p2_served_cut_over_whole` refuses the two draft
|
||||
tools before consent and takes them after.
|
||||
- **`ledger-cli` `tests/drafts.rs`:** `a_draft_waits_for_its_receipt_and_posts`,
|
||||
`the_gate_moved_and_the_mcp_error_is_unchanged`,
|
||||
`a_reversal_needs_no_receipt_of_its_own` (D-47),
|
||||
`discard_unblocks_the_close_over_the_wire`,
|
||||
`report_warns_on_draft_before_as_of` (and `strict`),
|
||||
`cpa_package_refuses_open_draft` (and `allow_open_drafts` journaling a
|
||||
decision), `stale_preview_refused` (a changed line, a changed argument,
|
||||
a write between preview and confirm, no preview),
|
||||
`every_confirm_tool_takes_a_preview`, `digest_since_seq_is_posting_time`
|
||||
(with `zero_net_accounts`, `drafts_by_state`, `never_reconciled`),
|
||||
`legacy_reversed_replays_identically` (the fixture over the wire),
|
||||
`gui_and_server_gate_agree` (three policies over random books; the
|
||||
viewer's missing-evidence set equals the engine's and equals the old
|
||||
reading of an entry's origin from the journal's events).
|
||||
- **`ledger-cli` `tests/attention_parity.rs`:**
|
||||
`a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists`.
|
||||
- **The sweep:** every over-the-wire harness confirms the way an agent does
|
||||
(`common::tools_call`: the call without `confirmed`, then with the
|
||||
`preview`); `books.receive_payment` and `books.pay_bill` gained
|
||||
`confirmed`; `ledger-mcp` `scopes.rs` tests pin them as preview-then-post.
|
||||
|
||||
**For P12 (mutation targets):** `evidence::requirement`'s exemption arms
|
||||
(reversal, closing, schedule month, depreciation run, disposal origin,
|
||||
payroll remittance origin, a tagged document line, a payroll run) and
|
||||
their order, the split-group branch taken only for a `Draft`, and
|
||||
`Policy::reason_for`'s three comparisons (required account, `<=` the
|
||||
threshold, every code exempt); `Ledger::origin_exemption`; the replay
|
||||
arms that note `AssetDisposal` and `PayrollRemittance`;
|
||||
`Ledger::discard_draft`'s empty-reason and posted refusals and the side
|
||||
tables it clears; `set_draft_state`'s `check_text`; `stamp_unstamped` and
|
||||
`expect_posted_seq` (a mutant that stamps the wrong line shows in
|
||||
`posted_seq_is_the_posting_lines_seq`); `Preview::operation` (the keys it
|
||||
drops: `confirmed`, `preview`, nulls) and `Preview::check`'s three arms
|
||||
(missing, equal, different); `post_entry`'s draft payload (each field of
|
||||
it); the digest's `since_seq` comparison (`>`), its `as_of` bound, the
|
||||
`zero_net_accounts` branch and the `never_reconciled` split;
|
||||
`report_status`'s three open conditions; the open-drafts refusal's
|
||||
`<= as_of` and its decision; the attention `awaiting_evidence` branch.
|
||||
Reference in new issue
Block a user