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:
swaitsandClaude Fable 5.1 committed 2026-10-29 13:55:38 -06:00
1 parent 7299cf90f2
commit 6a66a2855b
49 files changed
+2526 -202

No files matched your search

+10
View File
@@ -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
View File
@@ -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.
+16
View File
@@ -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).
+71
View File
@@ -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`
+75
View File
@@ -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.