fix: Windows reads of lock files, update paths cfg(unix), merge main again (P11c)
spec_vault swept every file under a book, lock files included; on Windows a held LockFileEx lock is mandatory and the read failed. The locks are already dedicated files (never data), so the test skips *.lock. Merged main (P13 step 6, the macOS server.log assert, P11a's AppImage identity): host.rs keeps main's five-field identity on Unix and size/last-write on Windows; the update poll and set_program_version are Unix-only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
7749afc557
234 files changed
+13028
-18411
No files matched your search
@@ -5,3 +5,7 @@
|
||||
- Tag what members paid for the company with the card or the cash they used (books.set_entry_instrument): their card is excluded, their own check counts. A card that only funded a friends-and-family transfer is their cash. Tag what a payment platform's balance paid the same way: goods and services excluded, friends-and-family counts; untagged, both stay undetermined.
|
||||
- Card and payment-network payments are excluded (the processor's Form 1099-K), whoever's card it was. A bank payment with no method recorded counts: tag a debit-card payment with the card.
|
||||
- books.vendor_1099 is the year-end worksheet for the CPA. undetermined means "tell me how it was paid" or "decide this vendor". With include_tins: true it carries the full TINs from the vault; csv: true also hands it over as a CSV file (run its download command to save it).
|
||||
|
||||
## How the worksheet is built (books.vendor_1099)
|
||||
|
||||
Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). It lists those payments, the W-9s missing, and each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold is not known is refused: pass threshold from the IRS. For the CPA to confirm.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Attestations and audit
|
||||
|
||||
An audit is the books read as an auditor reads them, through a day, in one run that gives the same findings for the same books. An attestation is a claim by a checker that it looked at something and found it right. The audit finds; the attestations stand behind it. The CPA package waits for both.
|
||||
|
||||
The receipt rule in force: {evidence_rule}
|
||||
|
||||
## Running an audit
|
||||
|
||||
- books.audit { through, scope } without confirmed lists the checks and changes nothing; show it to the human. Then call it again with confirmed: true and the preview. It files the workpaper (JSON and PDF, category workpaper) and journals the run. books.workpaper { id } gives it back: the JSON, and the PDF handed over (run its download command to save it).
|
||||
- scope: month (a month-end), annual (a fiscal year), cpa_handoff (the year, for the CPA package), full (everything from the start of the books).
|
||||
- It checks the trial balance, the accounting equation, the profit and loss against equity, the cash flow's footing, the A/R and A/P control accounts, closes, schedules and depreciation, the asset register, every posted entry against the receipt rule, open drafts, tax categories, 1099 decisions and the settings; then the journal's chain, the stored files, the access log and the backups. A finding is a failure (the books are wrong or cannot be shown right), a warning (unfinished or a rule not met) or a note (listed so the workpaper is whole: a reversal rests on its original's receipt).
|
||||
- Fix findings with journaled corrections (a reversal, a receipt attached, a decision recorded), never by editing: posted entries stay as they are. Then audit again.
|
||||
- The same books, day and scope give the run that exists: asking again files nothing. The books are "the same" while no entry, document, close, settings or role change has been made; filing a receipt, an audit or an attestation does not change them.
|
||||
- Books that have not declared audits refuse a confirmed audit and every attestation with format_upgrade_pending until the owner's upgrade (books.start says so). Nothing else about them changes.
|
||||
|
||||
## Attesting
|
||||
|
||||
Use separate verifier agents; none of them should be the one that made the entry. For each receipt:
|
||||
|
||||
1. books.get_receipt for the extracted text, then its page image, and read the total and the date off the receipt itself.
|
||||
2. books.attest_receipt { attachment_id, entry_id, amount, date, method, note }. The books compare what you read with the entry's amounts and date. A difference is recorded as disagrees and never counts: never bend your reading to fit.
|
||||
3. books.review_entry { entry_id, method } for each posted entry, once its lines, accounts, party and evidence have been read whole.
|
||||
4. books.attest_period { period, method } last, on a stand-still: it is refused unless an audit through the period's end still stands, which it does only while nothing has been posted, created, closed or set since the audit. If it is refused, audit again.
|
||||
|
||||
method is your own claim: model, reasoning_effort (low, medium, high, xhigh, max), adversarial (true when the verifier was set to find what is wrong), verifier_agents (how many independent ones checked). The books record it and cannot verify it; say what is true.
|
||||
|
||||
## What counts
|
||||
|
||||
books.attestations lists every claim with counted: whether it meets policy.attestation_bar as the bar stands when you ask. The default bar is reasoning effort high; a year-level audit (annual, cpa_handoff, full) also needs the check to be adversarial and at least two independent verifier agents. A bar change changes what counts, earlier claims too: nothing is versioned. A claim whose receipt reading disagrees with the entry never counts.
|
||||
|
||||
## The CPA package
|
||||
|
||||
Under policy.cpa_package_requires_audit = refuse (the default once the books hold an audit run; before one, the package is made and warns), books.export_cpa_package is refused unless an audit for the handoff (scope cpa_handoff or full) stands at the current books with a period attested on it at the bar. Pass without_audit_reason only when the human says to hand it over anyway; the reason is journaled as a decision. Under warn the package is made and says so.
|
||||
@@ -5,3 +5,9 @@
|
||||
- tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.
|
||||
- Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, a project's name as its QuickBooks class, the memos grouped); tell the human. Each line's project is its CLASS, the projects declared first as QuickBooks classes. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one.
|
||||
- With members, the K-1 worksheet is two files: k1-members.csv (each member's capital on the books, with their TIN) and k1-tax-basis-capital.csv (the same capital on the tax basis, as Schedule K-1 Item L reports it: property contributed at the member's adjusted basis, IRC §723, a prepaid contract handed over at its book amount for the CPA to confirm, and the reconciliation from book to tax capital). A line the books can't know (the share of income as figured for tax, always; tax depreciation; a disposal's tax gain; the basis of property no asset records) is left open with what the books have: tell the human the CPA supplies it; never fill it in.
|
||||
|
||||
## What the package holds (books.export_cpa_package)
|
||||
|
||||
A zip, handed over as a download (run the command the result gives; never file it into the books). It holds: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), the statement of changes in members' equity (multi-member LLCs), the tax summary and the trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits; warnings, and the README, say what is written differently from the books); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, beginning capital, contributions in cash and in property, share of income, distributions and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its section 723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, is left open and named); every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash.
|
||||
|
||||
By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins false gives last fours only. A locked vault or a missing TIN does not stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (for example ["1000"]) is required, since the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.
|
||||
@@ -5,3 +5,11 @@
|
||||
- If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy.
|
||||
- A file goes out by download alone. Files the books make (books.export in every format, books.asset_labels, books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder. They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
|
||||
- Receipts and documents come back out through books.get_receipt view bytes, handed over the same way: run its download command to save a copy in a folder you name.
|
||||
|
||||
## Importing a bank CSV (books.import_bank_csv)
|
||||
|
||||
A QuickBooks-style bank or credit-card CSV: 3 columns Date, Description, Amount (payments negative), or 4 columns Date, Description, money out, money in (Intuit heads them Credit, Debit). Slash dates must settle month-first or day-first from the file itself or from date_order ("month_first" for mm/dd, "day_first" for dd/mm); ask the human when the tool says the dates read either way. It creates one DRAFT entry per transaction, all or none: the bank account on one side, a categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).
|
||||
|
||||
## Exporting a report (books.export)
|
||||
|
||||
Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today).
|
||||
@@ -8,4 +8,4 @@
|
||||
- After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours.
|
||||
- This upgrade is `documents_v2`: invoice numbers, terms and line accounts (S4), sent records (S5), payments that settle several documents with early-payment discounts and a processor's fee (S6), and write-offs (S7). Until it is given, those writes are refused and everything else works as before.
|
||||
|
||||
- The journal features an upgrade can add: `members_cumulative`, `account_renamed`, `require_tax_category`, `tax_categories_gain_exempt`, `disposal_before_service`, `depreciation_catch_up`, `party_creditors`, `payment_instruments`, `vendor_1099_decisions`, `schedule_convention`, `month_reposting`, `asset_basis`, `projects`, `document_line_projects`, `document_standing`, `documents_v2`, `attachment_text` (P6: a filed file's extracted text, what a human annotated of it, and the entries a payment settles), and `drafts_v2` (P4: a draft's state, awaiting its evidence, and a discarded draft). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books.
|
||||
- The journal features an upgrade can add: `members_cumulative`, `account_renamed`, `require_tax_category`, `tax_categories_gain_exempt`, `disposal_before_service`, `depreciation_catch_up`, `party_creditors`, `payment_instruments`, `vendor_1099_decisions`, `schedule_convention`, `month_reposting`, `asset_basis`, `projects`, `document_line_projects`, `document_standing`, `documents_v2`, `attachment_text` (P6: a filed file's extracted text, what a human annotated of it, and the entries a payment settles), `drafts_v2` (P4: a draft's state, awaiting its evidence, and a discarded draft), and `source_revisions` (P5: correcting who decided a convention, a decision or an open item, and when). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books.
|
||||
@@ -13,3 +13,15 @@
|
||||
- Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9; never books.file_document or books.attach_receipt: the form shows the TIN).
|
||||
- A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed.
|
||||
- books.parties lists the directory (each party's TIN last four from its TIN or W-9 facts), books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
|
||||
|
||||
## Tagging an entry with several parties (books.set_entry_party)
|
||||
|
||||
Pass party (an id or a name), or parties: [{party, amount? | percent?}] for several. Each share is an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their company.members share). Once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to company.members never re-splits posted history. Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven.
|
||||
|
||||
Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that do not add up to what it moved. A party with a vendor record also tags the entry's vendor; a party without one, or clearing, clears the vendor tag. Metadata only: amounts never change. An entry and its reversal carry the same parties (tagging either tags both), so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). The result is the entry as books.get_entry gives it, each party's share resolved, with its direction and what of it was money or property in kind.
|
||||
|
||||
## Recording a W-9 (books.set_w9)
|
||||
|
||||
Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to "wanderbooks vault". The CPA package carries the full TIN to the CPA.
|
||||
|
||||
Give the signed form (a PDF, PNG or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. The result is the party (the TIN's last four only).
|
||||
@@ -4,3 +4,7 @@
|
||||
- Never confirm by forcing the numbers; find the difference.
|
||||
- books.void_reconciliation (with a reason) undoes a mistake; books.void_statement (with a reason) a mis-recorded statement, to record it again.
|
||||
- books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.
|
||||
|
||||
## What books.reconcile does
|
||||
|
||||
Give statement_attachment (a statement recorded with books.record_statement: its account, period end and closing balance), or account, statement_date and statement_balance for a quick check against any balance. It returns the book balance, the cleared balance, the statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed it is a preview and records nothing. With confirmed and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first.
|
||||
@@ -3,3 +3,7 @@
|
||||
- The human usually has the read-only viewer open beside you. When they ask to see something, and after you post, call books.show so it's on their screen: an entry, an account register, a report for any dates (by month, quarter or year, against the prior period or year), a receipt or document, an asset, a party, a reconciliation, or a search.
|
||||
- books.show says which dates it showed (without dates, the fiscal year to date; a register opens on it too, with "Show all dates" one click away for the human). It refuses a start after the end, and options its target ignores (dates for an entry, a split for the trial balance, a start for a report as of a day, a split into more than 240 columns): pass only what the target takes.
|
||||
- The viewer never changes the books; every change goes through these tools.
|
||||
|
||||
## What books.show can open
|
||||
|
||||
Exactly one target: an entry (with its lines, receipt and history); an account's register (optionally for start/end dates); a report (optionally for start/end dates, split into month, quarter or year columns, compared with the prior period or prior year); a stored receipt or document; a fixed asset (by tag or id); a party (by id or name: who they are and everything the company did with them); a project (by id or name); a reconciliation (by id: its account with its statements and reconciliations); a search of the transactions; or a page. Work out dates yourself ("last quarter" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. It changes nothing in the books.
|
||||
@@ -0,0 +1,8 @@
|
||||
Go through the year-end compliance work for {{year}} for {{company}}.
|
||||
|
||||
1. books.start, and books.open_items: what is already open, with its due dates.
|
||||
2. Read books.handbook sections 1099, parties-and-tins and rules-that-change. Work out the calendar for this entity type and list the filings and dates that apply; where you are unsure of a rule, raise it with books.raise_item for the human or the CPA rather than guessing.
|
||||
3. 1099 work: books.vendor_1099 for {{year}}; collect missing W-9s (books.set_w9; a full TIN goes only into the vault, never into your messages).
|
||||
4. Fixed assets: books.asset_schedule and books.run_depreciation for the year, previewed.
|
||||
5. Every month of {{year}} closed (the month_end_close prompt) and reconciled.
|
||||
6. Record each decision the human makes with books.record_decision, and each follow-up with books.raise_item.
|
||||
@@ -0,0 +1,8 @@
|
||||
Audit the books for scope {{scope}} (month, annual, cpa_handoff or full). Treat the books as something to disprove: look for what is wrong.
|
||||
|
||||
1. books.audit with scope {{scope}} and the day the audit covers: preview it, show the human what it will check, then run it with the preview. It checks the statements, the control accounts, closes, schedules, assets, every posted entry against the receipt policy, drafts, tax categories, 1099 decisions, settings, the chain, the stored files, the access log and the backups. books.workpaper gives the findings back.
|
||||
2. books.verify and books.control_check: the chain, the replay and every stored file, and the receivable and payable controls against their documents.
|
||||
3. books.digest for the scope's period, and read every posted entry in it against its evidence: books.search_entries, books.get_entry, books.get_receipt (its text first, then the page image). An entry that needs a receipt and has none, or whose receipt shows another amount, date or party, is a finding.
|
||||
4. Use separate verifier agents, none of them the one that made the entry. For each receipt, books.attest_receipt with the amount and date as read off it; for each entry, books.review_entry; say truthfully how each checked (method). A reading that differs from the entry never counts: never bend it to fit.
|
||||
5. books.trial_balance, books.profit_and_loss and books.balance_sheet for the period; compare them with the previous period and explain each large movement.
|
||||
6. Report findings to the human as a list: what, where (entry or account), why it matters, and the tool that fixes it. Fix nothing without the human's confirmation; corrections are reversing entries, not edits. Then audit again, and when it is clean books.attest_period; books.attestations shows which claims count at the bar.
|
||||
@@ -0,0 +1,8 @@
|
||||
Prepare the hand-off to the CPA for {{year}}.
|
||||
|
||||
1. books.start, then books.verify: the journal's chain, the replay and every stored file must be clean before anything leaves.
|
||||
2. Check each of the year's months is closed (books.list_periods) and reconciled (books.reconciliations); list what is not and stop if any is missing.
|
||||
3. books.control_check, and the 1099 work: books.vendor_1099 for the year, with W-9s in the vault (books.handbook section 1099).
|
||||
4. Read books.handbook section cpa-handoff, then tell the human what the package holds and whether the full TINs go in it (they go in by default; the human decides).
|
||||
5. books.export_cpa_package for the year. The package is handed over as a download: run the command the result gives, in a folder the human names, within 15 minutes.
|
||||
6. Offer books.digest for the year.
|
||||
@@ -0,0 +1,11 @@
|
||||
Close the month {{month}} for {{company}}. The close is data-driven: do each step in order, show the human what each one did, and stop at the first one that is not clean.
|
||||
|
||||
1. books.start for the rules, then books.conventions with kind close_step: this company's own close steps, in the order it keeps them. Follow them.
|
||||
2. Reconcile every tracked account for the month (the reconcile prompt, one account at a time); books.start lists the accounts that are behind.
|
||||
3. Review the financing accounts and the open drafts: books.list_drafts. A draft is posted, with its evidence, or discarded; none may stand in a closed month.
|
||||
4. Run what the month is owed: books.run_schedules, books.run_recurring and books.run_depreciation, each previewed first.
|
||||
5. Check the figures: books.trial_balance, books.profit_and_loss and books.balance_sheet for the month; books.control_check for the receivable and payable controls; books.verify for the chain.
|
||||
6. Only then books.close_period for the month, previewed and confirmed by the human.
|
||||
7. Offer books.digest of the month.
|
||||
|
||||
A closed month is never edited: a correction is a reversing entry in an open month.
|
||||
@@ -0,0 +1,11 @@
|
||||
You are setting up the books for {{company}} with the human. Work in this order and show the human each preview before anything is confirmed.
|
||||
|
||||
1. Call books.start and read the rules. If state is active, stop: these books are already set up; ask what the human wants instead.
|
||||
2. Ask for the company's facts (legal name, entity type, fiscal year end, time zone, base currency) and record them with books.set_setting; each call is previewed, then confirmed.
|
||||
3. Choose the chart of accounts: books.chart_template lists the templates by entity type; import a chart from a spreadsheet with books.import_chart_csv if the human has one. Read books.handbook section entries first.
|
||||
4. Record the members or owners, and the evidence policy (which entries need a receipt: books.handbook section evidence-and-receipts), the parties setting, and the attestation bar the human wants.
|
||||
5. Enter the opening balances as one dated entry through books.draft_entry and books.add_line, preview it with books.post_entry, and post it only after the human confirms.
|
||||
6. Check the opening trial balance with books.trial_balance against the human's own figures; every difference is explained or fixed before going on.
|
||||
7. Offer the viewer (books.handbook section viewer) and a backup (books.handbook section backups).
|
||||
|
||||
Never guess a figure, a date or a name: ask. Amounts are decimal strings. Anything you cannot find in the handbook, ask the human.
|
||||
@@ -0,0 +1,10 @@
|
||||
Reconcile account {{account}} for {{month}} against the bank's statement.
|
||||
|
||||
1. Call books.start, then books.reconciliations to see what is already reconciled for this account.
|
||||
2. Ask the human for the statement (the period's opening and closing balances and the statement file). Upload it as books.handbook section files-in-out says; never paste a file's contents.
|
||||
3. Record it with books.record_statement. If the statement's opening balance is not the last reconciled balance, stop and find out why before going on.
|
||||
4. Mark each transaction that appears on the statement as cleared with books.mark_cleared, matching by amount and date. Transactions on the statement with no entry are drafted (books.draft_entry), not forced.
|
||||
5. Run books.reconcile for the statement. It succeeds only when the cleared items agree with the statement's closing balance exactly. If it does not, list the difference and the candidates (uncleared entries, statement lines with no entry); do not plug it.
|
||||
6. Show the human the result with books.show, and offer books.digest.
|
||||
|
||||
A mistaken reconciliation is voided with books.void_reconciliation, never edited.
|
||||
@@ -86,6 +86,7 @@ pub(crate) struct Call {
|
||||
action: &'static str,
|
||||
subject: String,
|
||||
args_digest: Option<String>,
|
||||
agent: Option<ledger_store::access::Agent>,
|
||||
}
|
||||
|
||||
/// Why a call wasn't run, for its record and its answer.
|
||||
@@ -124,6 +125,7 @@ impl Call {
|
||||
action,
|
||||
subject: subject.to_string(),
|
||||
args_digest: None,
|
||||
agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +142,16 @@ impl Call {
|
||||
self.args_digest = args_digest;
|
||||
}
|
||||
|
||||
/// This call, with what the agent said about itself (`books.start`).
|
||||
pub(crate) fn with_agent(&mut self, agent: Option<ledger_store::access::Agent>) {
|
||||
self.agent = agent;
|
||||
}
|
||||
|
||||
/// The client program's name, as it named itself at `initialize`.
|
||||
pub(crate) fn client_name(&self) -> Option<String> {
|
||||
self.client.as_ref().map(|c| c.name.clone())
|
||||
}
|
||||
|
||||
/// The call's caller, if the door named one.
|
||||
pub(crate) fn caller(&self) -> Option<&Caller> {
|
||||
self.caller.as_ref()
|
||||
@@ -172,6 +184,7 @@ impl Call {
|
||||
args_digest: self.args_digest.clone(),
|
||||
result,
|
||||
head_after,
|
||||
agent: self.agent.clone(),
|
||||
};
|
||||
match self.log.record(&access) {
|
||||
Ok(_) => Ok(()),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! The guards: the named ways the books refuse a change, each with the
|
||||
//! tool that remedies it.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent that is refused is told which rule refused it and which
|
||||
//! tool to call next, in the same words every time.
|
||||
//! SOURCE: plan/served.md 4.6 (the result envelope: `guards` carry a code,
|
||||
//! a message and a remedy tool); DECISION (P5).
|
||||
//! INVARIANTS:
|
||||
//! 1. Every guard names a remedy tool that exists (test
|
||||
//! `every_guard_names_an_existing_tool`).
|
||||
//! 2. A code is spelled one way: the enum's `code()`; a refusal whose
|
||||
//! text is `{"code": .., "message": ..}` JSON or says a code in words
|
||||
//! becomes the guard of that code, and any other refusal has none.
|
||||
//! ```
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Every guard code the books know.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Guard {
|
||||
/// The preview a confirmation carries is not the one the books would
|
||||
/// show now.
|
||||
StalePreview,
|
||||
/// The books wait for the owner's format upgrade.
|
||||
FormatUpgradePending,
|
||||
/// The evidence policy wants a receipt before this posts.
|
||||
ReceiptRequired,
|
||||
/// An approval rule wants an approver's go-ahead.
|
||||
ApprovalRequired,
|
||||
/// The entry's party shares do not add up.
|
||||
PartyShares,
|
||||
/// The parties setting requires a party (or a no-party reason).
|
||||
PartyRequired,
|
||||
/// Drafts stand in the way of a close.
|
||||
OpenDrafts,
|
||||
/// A line on a control account carries no document tag.
|
||||
UntaggedControlLine,
|
||||
/// A vendor already has a bill with this reference.
|
||||
DuplicateVendorRef,
|
||||
/// The first invoice number cannot change once one is issued.
|
||||
FirstNumberLocked,
|
||||
/// No audit stands at the books' current head to rest this on (plan
|
||||
/// P8): run `books.audit`.
|
||||
AuditRequired,
|
||||
}
|
||||
|
||||
impl Guard {
|
||||
/// Every guard, for the registry test.
|
||||
pub(crate) const ALL: [Guard; 11] = [
|
||||
Guard::StalePreview,
|
||||
Guard::FormatUpgradePending,
|
||||
Guard::ReceiptRequired,
|
||||
Guard::ApprovalRequired,
|
||||
Guard::PartyShares,
|
||||
Guard::PartyRequired,
|
||||
Guard::OpenDrafts,
|
||||
Guard::UntaggedControlLine,
|
||||
Guard::DuplicateVendorRef,
|
||||
Guard::FirstNumberLocked,
|
||||
Guard::AuditRequired,
|
||||
];
|
||||
|
||||
/// The code an agent reads.
|
||||
pub(crate) fn code(self) -> &'static str {
|
||||
match self {
|
||||
Guard::StalePreview => "stale_preview",
|
||||
Guard::FormatUpgradePending => "format_upgrade_pending",
|
||||
Guard::ReceiptRequired => "receipt_required",
|
||||
Guard::ApprovalRequired => "approval_required",
|
||||
Guard::PartyShares => "party_shares",
|
||||
Guard::PartyRequired => "party_required",
|
||||
Guard::OpenDrafts => "open_drafts",
|
||||
Guard::UntaggedControlLine => "untagged_control_line",
|
||||
Guard::DuplicateVendorRef => "duplicate_vendor_ref",
|
||||
Guard::FirstNumberLocked => "first_number_locked",
|
||||
Guard::AuditRequired => "audit_required",
|
||||
}
|
||||
}
|
||||
|
||||
/// The guard of a code.
|
||||
pub(crate) fn from_code(code: &str) -> Option<Guard> {
|
||||
Self::ALL.into_iter().find(|g| g.code() == code)
|
||||
}
|
||||
|
||||
/// The tool that remedies it; `tool` is the call that was refused (a
|
||||
/// stale preview is remedied by previewing it again).
|
||||
pub(crate) fn remedy_tool(self, tool: &str) -> String {
|
||||
match self {
|
||||
Guard::StalePreview => return tool.to_string(),
|
||||
Guard::FormatUpgradePending => "books.start",
|
||||
Guard::ReceiptRequired => "books.attach_receipt",
|
||||
Guard::ApprovalRequired => "books.approve_entry",
|
||||
Guard::PartyShares | Guard::PartyRequired => "books.set_entry_party",
|
||||
Guard::OpenDrafts => "books.list_drafts",
|
||||
Guard::UntaggedControlLine => "books.control_check",
|
||||
Guard::DuplicateVendorRef => "books.search_entries",
|
||||
Guard::FirstNumberLocked => "books.settings",
|
||||
Guard::AuditRequired => "books.audit",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The guards a refusal's text carries, as the envelope shows them:
|
||||
/// `[{code, message, remedy_tool, remedy_args?}]`. `tool` is the refused
|
||||
/// call.
|
||||
pub(crate) fn from_refusal(tool: &str, text: &str) -> Vec<Value> {
|
||||
// A structured refusal: the JSON the tool built.
|
||||
if let Ok(Value::Object(o)) = serde_json::from_str::<Value>(text)
|
||||
&& let Some(code) = o.get("code").and_then(Value::as_str)
|
||||
{
|
||||
let remedy = o
|
||||
.get("remedy_tool")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or_else(|| Guard::from_code(code).map(|g| g.remedy_tool(tool)));
|
||||
if let Some(remedy) = remedy {
|
||||
let mut g = json!({
|
||||
"code": code,
|
||||
"message": o.get("message").and_then(Value::as_str).unwrap_or(code),
|
||||
"remedy_tool": remedy,
|
||||
});
|
||||
if let Some(entry) = o.get("entry_id") {
|
||||
g["remedy_args"] = json!({ "entry_id": entry });
|
||||
}
|
||||
return vec![g];
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
// A refusal that names its code in words (the store's).
|
||||
for g in Guard::ALL {
|
||||
if text.starts_with(g.code()) {
|
||||
return vec![json!({
|
||||
"code": g.code(),
|
||||
"message": text,
|
||||
"remedy_tool": g.remedy_tool(tool),
|
||||
})];
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::server::BooksServer;
|
||||
|
||||
#[test]
|
||||
fn every_guard_names_an_existing_tool() {
|
||||
let names: Vec<String> = BooksServer::tool_router()
|
||||
.list_all()
|
||||
.into_iter()
|
||||
.map(|t| t.name.to_string())
|
||||
.collect();
|
||||
for g in Guard::ALL {
|
||||
let remedy = g.remedy_tool("books.post_entry");
|
||||
assert!(
|
||||
names.contains(&remedy),
|
||||
"guard {} names {remedy}, which is no tool",
|
||||
g.code()
|
||||
);
|
||||
assert_eq!(Guard::from_code(g.code()), Some(g));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_becomes_its_guard() {
|
||||
let text = json!({"code":"approval_required","message":"needs approval","entry_id":7,"remedy_tool":"books.approve_entry"}).to_string();
|
||||
let g = from_refusal("books.post_entry", &text);
|
||||
assert_eq!(g[0]["code"], "approval_required");
|
||||
assert_eq!(g[0]["remedy_args"]["entry_id"], 7);
|
||||
let g = from_refusal(
|
||||
"books.create_invoice",
|
||||
"format_upgrade_pending: this needs documents_v2",
|
||||
);
|
||||
assert_eq!(g[0]["remedy_tool"], "books.start");
|
||||
assert!(from_refusal("books.x", "no such account").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
||||
),
|
||||
("1099", include_str!("../handbook/1099.md")),
|
||||
("cpa-handoff", include_str!("../handbook/cpa-handoff.md")),
|
||||
(
|
||||
"attestations-and-audit",
|
||||
include_str!("../handbook/attestations-and-audit.md"),
|
||||
),
|
||||
(
|
||||
"settings-and-conventions",
|
||||
include_str!("../handbook/settings-and-conventions.md"),
|
||||
|
||||
@@ -1121,6 +1121,7 @@ async fn refused_at_door(
|
||||
args_digest: None,
|
||||
result: Outcome::Refused(code.into()),
|
||||
head_after: None,
|
||||
agent: None,
|
||||
};
|
||||
let written = tokio::task::spawn_blocking(move || access.record(&record)).await;
|
||||
if !matches!(written, Ok(Ok(_))) {
|
||||
@@ -1350,6 +1351,7 @@ async fn upload(
|
||||
args_digest: None,
|
||||
result: outcome.clone(),
|
||||
head_after: None,
|
||||
agent: None,
|
||||
};
|
||||
let recorded = access.record(&record).is_ok();
|
||||
if !recorded && received.is_ok() {
|
||||
@@ -1524,6 +1526,7 @@ async fn fetch(
|
||||
Err((_, code, _)) => Outcome::Refused((*code).into()),
|
||||
},
|
||||
head_after: None,
|
||||
agent: None,
|
||||
};
|
||||
let recorded = access.record(&record).is_ok();
|
||||
(found, recorded)
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
//! The result envelope: what every tool result carries besides its own
|
||||
//! fields (plan 4.6).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent never has to guess what to do after a call: every result
|
||||
//! says what follows (`next_steps`), what refused it and which tool
|
||||
//! remedies that (`guards`), what to tell the human (`warnings`),
|
||||
//! and, on a tool that can change the books, where the journal ends
|
||||
//! now (`head`).
|
||||
//! SOURCE: plan/served.md 4.6 and P5 step 1; DECISION (P5).
|
||||
//! INVARIANTS:
|
||||
//! 1. The envelope is applied in one place (`apply`, called by
|
||||
//! `call_tool`), so no tool can forget it.
|
||||
//! 2. Every result of a tool that is not read-only has a `head` (the
|
||||
//! journal after the call's last write) and at least one next step; a
|
||||
//! read has `next_steps` when something follows it and nothing else (a
|
||||
//! resource and its tool agree). `guards` is on a refusal, and a
|
||||
//! success without warnings has no `warnings` key.
|
||||
//! 3. A tool's own fields are never overwritten: a payload that already
|
||||
//! has `next_steps`, `guards`, `warnings` or `head` keeps it.
|
||||
//! 4. A refused call has no head and no steps; a refusal that is a
|
||||
//! guard (guards.rs) carries it as `structuredContent.guards`.
|
||||
//! 5. Next steps from the books' attention list are the viewer's own
|
||||
//! items (ledger_package::attention), filtered to the subject the
|
||||
//! call touched.
|
||||
//! ```
|
||||
|
||||
use super::BooksServer;
|
||||
use super::guards;
|
||||
use ledger_package::{Integrity, Severity, Target};
|
||||
use rmcp::model::{CallToolResponse, ContentBlock};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// One thing to do next.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Step {
|
||||
/// The tool to call.
|
||||
pub tool: Option<String>,
|
||||
/// Or the prompt to run.
|
||||
pub prompt: Option<String>,
|
||||
/// Or a command for the human's computer (a download).
|
||||
pub command: Option<String>,
|
||||
/// Its arguments.
|
||||
pub args: Option<Value>,
|
||||
/// Why.
|
||||
pub why: String,
|
||||
/// Whether the work is not done without it.
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
impl Step {
|
||||
pub(crate) fn tool(tool: &str, args: Option<Value>, why: &str, required: bool) -> Step {
|
||||
Step {
|
||||
tool: Some(tool.into()),
|
||||
prompt: None,
|
||||
command: None,
|
||||
args,
|
||||
why: why.into(),
|
||||
required,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_json(&self) -> Value {
|
||||
let mut o = Map::new();
|
||||
if let Some(t) = &self.tool {
|
||||
o.insert("tool".into(), json!(t));
|
||||
}
|
||||
if let Some(p) = &self.prompt {
|
||||
o.insert("prompt".into(), json!(p));
|
||||
}
|
||||
if let Some(c) = &self.command {
|
||||
o.insert("command".into(), json!(c));
|
||||
}
|
||||
if let Some(a) = &self.args {
|
||||
o.insert("args".into(), a.clone());
|
||||
}
|
||||
o.insert("why".into(), json!(self.why));
|
||||
o.insert("required".into(), json!(self.required));
|
||||
Value::Object(o)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tools that are not read-only: their results carry a `head`.
|
||||
pub(crate) fn mutating_tools() -> &'static HashSet<String> {
|
||||
static SET: OnceLock<HashSet<String>> = OnceLock::new();
|
||||
SET.get_or_init(|| {
|
||||
BooksServer::tool_router()
|
||||
.list_all()
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
!t.annotations
|
||||
.as_ref()
|
||||
.and_then(|a| a.read_only_hint)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|t| t.name.to_string())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The steps a tool's own result implies (no books needed): the confirm
|
||||
/// after a preview, and what follows a particular act.
|
||||
pub(crate) fn tool_steps(
|
||||
tool: &str,
|
||||
args: Option<&Map<String, Value>>,
|
||||
result: &Value,
|
||||
) -> Vec<Step> {
|
||||
let mut steps = Vec::new();
|
||||
let confirmed = args
|
||||
.and_then(|a| a.get("confirmed"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let entry = result
|
||||
.get("entry_id")
|
||||
.or_else(|| args.and_then(|a| a.get("entry_id")))
|
||||
.and_then(Value::as_u64);
|
||||
let with_entry = |id: u64| Some(json!({ "entry_id": id }));
|
||||
// A preview: what is missing first, then the human confirms, then the
|
||||
// same call with the digest.
|
||||
// An audit already run is not previewed: its run is the answer.
|
||||
let already_run = tool == "books.audit"
|
||||
&& result.get("status").and_then(Value::as_str) == Some("already_run");
|
||||
if let (false, Some(preview), Some(args)) = (
|
||||
confirmed || already_run,
|
||||
result.get("preview").and_then(Value::as_str),
|
||||
args,
|
||||
) {
|
||||
if tool == "books.post_entry"
|
||||
&& result.get("receipt_missing").and_then(Value::as_bool) == Some(true)
|
||||
&& let Some(id) = entry
|
||||
{
|
||||
steps.push(Step::tool(
|
||||
"books.attach_receipt",
|
||||
with_entry(id),
|
||||
"This entry needs a receipt before it posts: attach the file (upload it first).",
|
||||
true,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.set_draft_state",
|
||||
Some(json!({ "entry_id": id, "state": "awaiting_evidence" })),
|
||||
"Or leave the draft waiting, with a note saying what for, until the receipt arrives.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
let mut confirm = args.clone();
|
||||
confirm.insert("confirmed".into(), json!(true));
|
||||
confirm.insert("preview".into(), json!(preview));
|
||||
steps.push(Step::tool(
|
||||
tool,
|
||||
Some(Value::Object(confirm)),
|
||||
"Show this preview to the human; only when they agree, make the same call with confirmed true and this preview.",
|
||||
true,
|
||||
));
|
||||
return steps;
|
||||
}
|
||||
match tool {
|
||||
"books.set_setting" | "books.add_account" => {
|
||||
steps.push(Step::tool(
|
||||
"books.add_account",
|
||||
None,
|
||||
"Add the accounts the human's chart needs, one at a time.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.draft_entry",
|
||||
None,
|
||||
"Or record a transaction: draft it, add its lines, preview it, then post.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
"books.draft_entry" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.add_line",
|
||||
with_entry(id),
|
||||
"Add the draft's lines: each is an account, a side and an amount; debits must equal credits.",
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.add_line" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.add_line",
|
||||
with_entry(id),
|
||||
"Add the next line of the draft.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.post_entry",
|
||||
with_entry(id),
|
||||
"When the lines balance, preview the post for the human.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.set_draft_state" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.attach_receipt",
|
||||
with_entry(id),
|
||||
"When the document arrives, upload and attach it to this draft.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.attach_receipt" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.post_entry",
|
||||
with_entry(id),
|
||||
"The evidence is attached: preview the post again for the human.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.post_entry" | "books.reverse_entry" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.show",
|
||||
with_entry(id),
|
||||
"Show the human the entry and what it moved.",
|
||||
false,
|
||||
));
|
||||
if tool == "books.post_entry" {
|
||||
steps.push(Step::tool(
|
||||
"books.reverse_entry",
|
||||
with_entry(id),
|
||||
"Only if this was posted in error: reverse it (posted entries are never edited).",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
"books.draft_from_template" => {
|
||||
if let Some(id) = entry {
|
||||
steps.push(Step::tool(
|
||||
"books.post_entry",
|
||||
with_entry(id),
|
||||
"Preview the draft for the human, who then confirms it.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.record_statement" => {
|
||||
steps.push(Step::tool(
|
||||
"books.mark_cleared",
|
||||
None,
|
||||
"Mark each transaction the statement shows as cleared.",
|
||||
true,
|
||||
));
|
||||
let (account, end, close, attachment) = (
|
||||
result.get("account_code"),
|
||||
result.get("period_end"),
|
||||
result.get("closing_balance"),
|
||||
result.get("attachment_id"),
|
||||
);
|
||||
if let (Some(account), Some(end), Some(close), Some(attachment)) =
|
||||
(account, end, close, attachment)
|
||||
{
|
||||
steps.push(Step::tool(
|
||||
"books.reconcile",
|
||||
Some(json!({
|
||||
"account": account,
|
||||
"statement_date": end,
|
||||
"statement_balance": close,
|
||||
"statement_attachment": attachment,
|
||||
})),
|
||||
"When every line is marked, preview the reconciliation against the statement.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.mark_cleared" => steps.push(Step::tool(
|
||||
"books.reconcile",
|
||||
None,
|
||||
"Mark the rest, then preview the reconciliation against the statement.",
|
||||
false,
|
||||
)),
|
||||
"books.reconcile" => steps.push(Step::tool(
|
||||
"books.reconciliations",
|
||||
None,
|
||||
"See the reconciliation among the account's others.",
|
||||
false,
|
||||
)),
|
||||
"books.list_periods" => {
|
||||
for p in result
|
||||
.get("periods")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|p| p.get("status").and_then(Value::as_str) == Some("open"))
|
||||
.take(3)
|
||||
{
|
||||
if let Some(id) = p.get("id").and_then(Value::as_u64) {
|
||||
steps.push(Step::tool(
|
||||
"books.close_period",
|
||||
Some(json!({ "period_id": id })),
|
||||
"Once everything in this period is recorded and reconciled, preview its close.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
"books.close_period" => {
|
||||
let period = result
|
||||
.get("period_id")
|
||||
.or_else(|| args.and_then(|a| a.get("period_id")))
|
||||
.and_then(Value::as_u64);
|
||||
// The chain first (cheap, and what every close has always
|
||||
// offered: the month_end_close eval follows it), then the
|
||||
// audit the attestation rests on (P8).
|
||||
steps.push(Step::tool(
|
||||
"books.verify",
|
||||
None,
|
||||
"Verify the journal's chain and the projection after the close.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.audit",
|
||||
Some(json!({ "scope": "month" })),
|
||||
"Audit the books through the month's end; the attestation rests on that run.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.attest_period",
|
||||
period.map(|p| json!({ "period": p.to_string() })),
|
||||
"Once the audit is run and the receipts read, attest the period you closed.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
"books.audit" => {
|
||||
if let Some(id) = result.pointer("/run/id").and_then(Value::as_u64) {
|
||||
steps.push(Step::tool(
|
||||
"books.workpaper",
|
||||
Some(json!({ "id": id })),
|
||||
"Read the workpaper: what was checked and every finding. Fix findings with journaled corrections, then audit again.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.attest_receipt",
|
||||
None,
|
||||
"Have separate verifier agents read each receipt (books.get_receipt, then its page image) and attest it.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.review_entry",
|
||||
None,
|
||||
"Review each posted entry whole.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.attest_period",
|
||||
None,
|
||||
"Then attest the period this audit covers.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
"books.attest_receipt" => steps.push(Step::tool(
|
||||
"books.review_entry",
|
||||
args.and_then(|a| a.get("entry_id"))
|
||||
.map(|e| json!({ "entry_id": e })),
|
||||
"Review the whole entry the receipt supports.",
|
||||
false,
|
||||
)),
|
||||
"books.review_entry" => steps.push(Step::tool(
|
||||
"books.attestations",
|
||||
None,
|
||||
"See which claims count at the bar.",
|
||||
false,
|
||||
)),
|
||||
"books.attest_period" => {
|
||||
steps.push(Step::tool(
|
||||
"books.attestations",
|
||||
None,
|
||||
"See which claims count at the bar before the CPA package.",
|
||||
false,
|
||||
));
|
||||
steps.push(Step::tool(
|
||||
"books.export_cpa_package",
|
||||
None,
|
||||
"When the year is audited and attested at the bar, hand the books to the CPA.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
"books.export_cpa_package" => {
|
||||
if let Some(cmd) = result.pointer("/file/download").and_then(Value::as_str) {
|
||||
steps.push(Step {
|
||||
tool: None,
|
||||
prompt: None,
|
||||
command: Some(cmd.to_string()),
|
||||
args: None,
|
||||
why: "Run this to save the package in a folder you name, within 15 minutes."
|
||||
.into(),
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// The attention items about the subject of the call, as steps.
|
||||
fn attention_steps(
|
||||
server: &BooksServer,
|
||||
subject: Subject,
|
||||
store: &ledger_store::Store,
|
||||
) -> Vec<Step> {
|
||||
let cfg = server.settings();
|
||||
let settings = ledger_package::Settings {
|
||||
company: &cfg.company,
|
||||
cash_accounts: &cfg.roles.cash,
|
||||
financing_accounts: &cfg.roles.financing,
|
||||
investing_accounts: &cfg.roles.investing,
|
||||
};
|
||||
let today = ledger_core::settings::today_in(cfg.timezone.as_deref());
|
||||
let items = ledger_package::find_with(store, today, &settings, &Integrity::default());
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|a| match (subject, a.target) {
|
||||
(Subject::Entry(s), Target::Entry(t)) => s == t,
|
||||
(Subject::Invoice(s), Target::Invoice(t)) => s == t,
|
||||
_ => false,
|
||||
})
|
||||
.filter_map(|a| {
|
||||
let (tool, args) = a.tool?;
|
||||
let args = if args.is_null() {
|
||||
match (a.target, tool.as_str()) {
|
||||
(
|
||||
Target::Entry(id),
|
||||
"books.attach_receipt"
|
||||
| "books.set_entry_party"
|
||||
| "books.set_entry_settles",
|
||||
) => Some(json!({ "entry_id": id })),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
Some(args)
|
||||
};
|
||||
let title: String = a.title.chars().take(160).collect();
|
||||
Some(Step::tool(
|
||||
&tool,
|
||||
args,
|
||||
&title,
|
||||
a.severity == Severity::Problem,
|
||||
))
|
||||
})
|
||||
.take(4)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What a call touched, for the attention filter.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Subject {
|
||||
Entry(u64),
|
||||
Invoice(u64),
|
||||
}
|
||||
|
||||
fn subject_of(args: Option<&Map<String, Value>>, result: &Value) -> Option<Subject> {
|
||||
let pick = |k: &str| {
|
||||
result
|
||||
.get(k)
|
||||
.or_else(|| args.and_then(|a| a.get(k)))
|
||||
.and_then(Value::as_u64)
|
||||
};
|
||||
pick("entry_id")
|
||||
.map(Subject::Entry)
|
||||
.or_else(|| pick("invoice_id").map(Subject::Invoice))
|
||||
}
|
||||
|
||||
/// The fallback of a tool that changes things and has nothing particular
|
||||
/// to follow it.
|
||||
fn digest_step() -> Step {
|
||||
Step::tool(
|
||||
"books.digest",
|
||||
None,
|
||||
"See what changed and offer it to the human.",
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Put the envelope on a tool's answer (INVARIANT 1). `wrote` is the head
|
||||
/// after the call's last write, when it wrote.
|
||||
pub(crate) fn apply(
|
||||
server: &BooksServer,
|
||||
tool: &str,
|
||||
args: Option<&Map<String, Value>>,
|
||||
out: Result<CallToolResponse, rmcp::ErrorData>,
|
||||
) -> Result<CallToolResponse, rmcp::ErrorData> {
|
||||
let Ok(CallToolResponse::Complete(mut r)) = out else {
|
||||
return out;
|
||||
};
|
||||
if r.is_error == Some(true) {
|
||||
// INVARIANT 4.
|
||||
let text = r
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
ContentBlock::Text(t) => Some(t.text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or("");
|
||||
let guards = guards::from_refusal(tool, text);
|
||||
if !guards.is_empty() && r.structured_content.is_none() {
|
||||
r.structured_content = Some(json!({ "guards": guards }));
|
||||
}
|
||||
return Ok(CallToolResponse::Complete(r));
|
||||
}
|
||||
let Some(Value::Object(mut body)) = r.structured_content.take() else {
|
||||
return Ok(CallToolResponse::Complete(r));
|
||||
};
|
||||
let before = Value::Object(body.clone());
|
||||
let mutating = mutating_tools().contains(tool);
|
||||
let result = Value::Object(body.clone());
|
||||
let mut steps = tool_steps(tool, args, &result);
|
||||
let head = if mutating {
|
||||
server.lock().ok().map(|store| {
|
||||
let (seq, hash) = store.head();
|
||||
if let Some(subject) = subject_of(args, &result)
|
||||
&& steps.iter().all(|s| !s.required)
|
||||
{
|
||||
steps.extend(attention_steps(server, subject, &store));
|
||||
}
|
||||
json!({ "seq": seq, "hash": hash })
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if mutating && steps.is_empty() {
|
||||
steps.push(digest_step());
|
||||
}
|
||||
// A tool that can change the books always says what follows; a read
|
||||
// says it only when something does, so what it says is what a resource
|
||||
// of the same name says (`books://settings` is `books.settings`).
|
||||
// `guards` is on a refusal (above) and `warnings` is the tool's own:
|
||||
// a success that has none says none, as the tools always did.
|
||||
if !body.contains_key("next_steps") && (mutating || !steps.is_empty()) {
|
||||
body.insert(
|
||||
"next_steps".into(),
|
||||
Value::Array(steps.iter().map(Step::to_json).collect()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(head) = head {
|
||||
body.entry("head").or_insert(head);
|
||||
}
|
||||
let after = Value::Object(body);
|
||||
// The same JSON in the text block (server.rs INVARIANT 1).
|
||||
for c in &mut r.content {
|
||||
if let ContentBlock::Text(t) = c
|
||||
&& serde_json::from_str::<Value>(&t.text).is_ok_and(|v| v == before)
|
||||
{
|
||||
t.text = after.to_string();
|
||||
}
|
||||
}
|
||||
r.structured_content = Some(after);
|
||||
Ok(CallToolResponse::Complete(r))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_preview_gets_its_confirm_step() {
|
||||
let args = json!({"entry_id": 3}).as_object().cloned().unwrap();
|
||||
let steps = tool_steps(
|
||||
"books.post_entry",
|
||||
Some(&args),
|
||||
&json!({"entry_id": 3, "preview": "abc", "status": "preview"}),
|
||||
);
|
||||
assert_eq!(steps.len(), 1);
|
||||
let a = steps[0].args.as_ref().unwrap();
|
||||
assert_eq!(a["confirmed"], true);
|
||||
assert_eq!(a["preview"], "abc");
|
||||
assert!(steps[0].required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_step_a_tool_names_exists() {
|
||||
let names: HashSet<String> = BooksServer::tool_router()
|
||||
.list_all()
|
||||
.into_iter()
|
||||
.map(|t| t.name.to_string())
|
||||
.collect();
|
||||
let result = json!({"entry_id": 1, "file": {"download": "x"}});
|
||||
for tool in mutating_tools() {
|
||||
for s in tool_steps(tool, Some(&Map::new()), &result)
|
||||
.into_iter()
|
||||
.chain([digest_step()])
|
||||
{
|
||||
if let Some(t) = s.tool {
|
||||
assert!(
|
||||
names.contains(&t),
|
||||
"{tool}: step names {t}, which is no tool"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub(crate) fn capabilities() -> ServerCapabilities {
|
||||
ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_resources()
|
||||
.enable_prompts()
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -85,5 +86,6 @@ mod tests {
|
||||
);
|
||||
assert!(r["capabilities"]["tools"].is_object());
|
||||
assert!(r["capabilities"]["resources"].is_object());
|
||||
assert!(r["capabilities"]["prompts"].is_object());
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
|
||||
const K: Needs = PostForApprovalRules;
|
||||
|
||||
/// Every tool and what it needs (INVARIANT 1).
|
||||
pub const TABLE: [(&str, Needs); 133] = [
|
||||
pub const TABLE: [(&str, Needs); 140] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
// Settings are committed at once: the settings commit of the plan.
|
||||
@@ -200,7 +200,17 @@ pub const TABLE: [(&str, Needs); 133] = [
|
||||
("books.list_drafts", R),
|
||||
("books.set_draft_state", W),
|
||||
("books.discard_draft", C),
|
||||
// P5
|
||||
("books.journal_history", R),
|
||||
("books.preview_entry", R),
|
||||
// P8: audits and attestations (an audit previews, then runs; a claim
|
||||
// that someone checked is a write, not a post).
|
||||
("books.audit", C),
|
||||
("books.workpaper", R),
|
||||
("books.attest_receipt", W),
|
||||
("books.review_entry", W),
|
||||
("books.attest_period", W),
|
||||
("books.attestations", R),
|
||||
];
|
||||
|
||||
/// What `tool` needs, given its arguments; None for a tool this server
|
||||
|
||||
@@ -584,6 +584,8 @@ impl BooksServer {
|
||||
+ Self::tax_router()
|
||||
+ Self::write_offs_router()
|
||||
+ Self::drafts_router()
|
||||
+ Self::journal_router()
|
||||
+ Self::audit_router()
|
||||
}
|
||||
|
||||
/// The settings in force for this request: the books' own, taken from
|
||||
@@ -1264,6 +1266,64 @@ fn resource_list() -> Vec<Resource> {
|
||||
resources
|
||||
}
|
||||
|
||||
/// The advertised resource templates (plan 4.5; RFC 6570): the URIs a
|
||||
/// client can build, each read by `resource_body`.
|
||||
fn resource_templates() -> Vec<rmcp::model::ResourceTemplate> {
|
||||
use rmcp::model::ResourceTemplate;
|
||||
let mk = |uri: &str, name: &str, title: &str, description: &str| {
|
||||
ResourceTemplate::new(uri, name)
|
||||
.with_title(title)
|
||||
.with_description(description)
|
||||
.with_mime_type("application/json")
|
||||
};
|
||||
vec![
|
||||
mk(
|
||||
"books://entries/{id}",
|
||||
"entry",
|
||||
"An entry",
|
||||
"One entry with its lines, receipts and history (books.get_entry).",
|
||||
),
|
||||
mk(
|
||||
"books://attachments/{id}",
|
||||
"attachment",
|
||||
"A stored file",
|
||||
"A receipt or document: its metadata and the start of its text (books.get_receipt); its pages are books://attachments/{id}/page/{n}.",
|
||||
),
|
||||
mk(
|
||||
"books://journal/{seq}",
|
||||
"journal-line",
|
||||
"A journal line",
|
||||
"One journal line in words (books.journal_history).",
|
||||
),
|
||||
mk(
|
||||
"books://reports/{kind}{?start,end,as_of}",
|
||||
"report",
|
||||
"A report",
|
||||
"trial-balance, balance-sheet or profit-and-loss, for a day (as_of) or a range (start, end); without them as of today, or the year to date.",
|
||||
),
|
||||
mk(
|
||||
"books://digest{?since,since_seq,as_of}",
|
||||
"digest",
|
||||
"The activity digest",
|
||||
"What changed, since a date, since a journal line (since_seq), as of a day; bare, this month so far.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Prompts are listed and read by a token that may read the books.
|
||||
fn prompt_reader(context: &RequestContext<RoleServer>) -> Result<(), rmcp::ErrorData> {
|
||||
match crate::access::caller(context) {
|
||||
None => Err(rmcp::ErrorData::invalid_request(
|
||||
crate::access::UNAUTHORIZED,
|
||||
None,
|
||||
)),
|
||||
Some(c) if !c.may(ledger_store::auth::Scope::Read) => Err(
|
||||
rmcp::ErrorData::invalid_request(crate::access::INSUFFICIENT_SCOPE, None),
|
||||
),
|
||||
Some(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The subject kinds, for messages.
|
||||
const SUBJECT_KINDS: &str = "entry, account, asset, party, project, invoice, bill, period, payroll_run, schedule, reconciliation, company";
|
||||
|
||||
@@ -1321,6 +1381,13 @@ thread_local! {
|
||||
/// The journal's head after the last write made by the request
|
||||
/// running on this thread (StoreGuard's drop), for its access record.
|
||||
static WROTE: std::cell::RefCell<Option<(u64, String)>> = const { std::cell::RefCell::new(None) };
|
||||
/// Who the request running on this thread is, as `books.start` shows
|
||||
/// it: `{principal, kind, scopes, client_id}`. The token's own facts
|
||||
/// and the client's name; set at the start of the request.
|
||||
static YOU: std::cell::RefCell<Option<serde_json::Value>> = const { std::cell::RefCell::new(None) };
|
||||
/// What the agent said about itself in a `books.start` this request is
|
||||
/// running (`agent`), for the call's access record.
|
||||
static AGENT_NOTE: std::cell::RefCell<Option<ledger_store::access::Agent>> = const { std::cell::RefCell::new(None) };
|
||||
/// The company's time zone for the request running on this thread
|
||||
/// (set at its start from the settings in force: one version per
|
||||
/// request), so every "today" in it agrees.
|
||||
@@ -1358,6 +1425,16 @@ fn today() -> NaiveDate {
|
||||
TIMEZONE.with(|z| ledger_core::settings::today_in(z.borrow().as_deref()))
|
||||
}
|
||||
|
||||
/// A resource URI's query parameter `key`, when given and not empty.
|
||||
fn query_param(query: &str, key: &str) -> Option<String> {
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix(&format!("{key}=")))
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// The `since` of a `books://digest` read: the query's `since=`, or, bare
|
||||
/// or with an empty `since=`, the first day of `today`'s month (this
|
||||
/// month so far).
|
||||
@@ -1397,12 +1474,20 @@ mod access_tools;
|
||||
mod backup_tools;
|
||||
#[path = "tools/files.rs"]
|
||||
mod files;
|
||||
#[path = "guards.rs"]
|
||||
mod guards;
|
||||
#[path = "tools/instruments.rs"]
|
||||
mod instruments;
|
||||
#[path = "tools/journal.rs"]
|
||||
mod journal_tools;
|
||||
#[path = "tools/kept.rs"]
|
||||
mod kept;
|
||||
#[path = "next_steps.rs"]
|
||||
mod next_steps;
|
||||
#[path = "tools/projects.rs"]
|
||||
mod projects;
|
||||
#[path = "tools/prompts.rs"]
|
||||
mod prompts;
|
||||
pub use kept::SettingKeyOut;
|
||||
|
||||
// The tool groups: one file each (plan/served.md 8.2 U0a). A unit adds a
|
||||
@@ -1412,6 +1497,8 @@ pub use kept::SettingKeyOut;
|
||||
mod assets;
|
||||
#[path = "tools/attachments.rs"]
|
||||
mod attachments;
|
||||
#[path = "tools/audit.rs"]
|
||||
mod audit_tools;
|
||||
#[path = "tools/chart.rs"]
|
||||
mod chart;
|
||||
#[path = "tools/documents.rs"]
|
||||
@@ -1500,14 +1587,17 @@ impl BooksServer {
|
||||
}
|
||||
"books://reports/trial-balance" => {
|
||||
let Json(out) = self
|
||||
.trial_balance(Parameters(TrialBalanceParams::default()))
|
||||
.trial_balance(Parameters(TrialBalanceParams {
|
||||
as_of: query_param(query, "as_of"),
|
||||
..TrialBalanceParams::default()
|
||||
}))
|
||||
.await?;
|
||||
json(&out)
|
||||
}
|
||||
"books://reports/balance-sheet" => {
|
||||
let Json(out) = self
|
||||
.balance_sheet(Parameters(BalanceSheetParams {
|
||||
as_of: None,
|
||||
as_of: query_param(query, "as_of"),
|
||||
strict: None,
|
||||
}))
|
||||
.await?;
|
||||
@@ -1516,8 +1606,8 @@ impl BooksServer {
|
||||
"books://reports/profit-and-loss" => {
|
||||
let Json(out) = self
|
||||
.profit_and_loss(Parameters(ProfitAndLossParams {
|
||||
start: None,
|
||||
end: None,
|
||||
start: query_param(query, "start"),
|
||||
end: query_param(query, "end"),
|
||||
class: None,
|
||||
strict: None,
|
||||
}))
|
||||
@@ -1525,16 +1615,56 @@ impl BooksServer {
|
||||
json(&out)
|
||||
}
|
||||
"books://digest" => {
|
||||
let since = digest_since(query, today());
|
||||
// `since_seq` (what was posted since a journal line) wins
|
||||
// over `since`; with neither, this month so far.
|
||||
let since_seq = query_param(query, "since_seq")
|
||||
.map(|n| {
|
||||
n.parse::<u64>()
|
||||
.map_err(|_| format!("bad since_seq in {uri}"))
|
||||
})
|
||||
.transpose()?;
|
||||
let since = if since_seq.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(digest_since(query, today()))
|
||||
};
|
||||
let Json(out) = self
|
||||
.digest(Parameters(DigestParams {
|
||||
since: Some(since),
|
||||
since_seq: None,
|
||||
as_of: None,
|
||||
since,
|
||||
since_seq,
|
||||
as_of: query_param(query, "as_of"),
|
||||
}))
|
||||
.await?;
|
||||
json(&out)
|
||||
}
|
||||
_ if path.starts_with("books://entries/") => {
|
||||
let id: u64 = path["books://entries/".len()..]
|
||||
.parse()
|
||||
.map_err(|_| format!("bad entry resource URI: {uri}"))?;
|
||||
let Json(out) = self
|
||||
.get_entry(Parameters(GetEntryParams { entry_id: id }))
|
||||
.await?;
|
||||
json(&out)
|
||||
}
|
||||
_ if path.starts_with("books://journal/") => {
|
||||
let seq: u64 = path["books://journal/".len()..]
|
||||
.parse()
|
||||
.map_err(|_| format!("bad journal resource URI: {uri}"))?;
|
||||
if seq == 0 {
|
||||
return Err("journal seqs start at 1".into());
|
||||
}
|
||||
let Json(out) = self
|
||||
.journal_history(Parameters(journal_tools::JournalHistoryParams {
|
||||
after_seq: Some(seq - 1),
|
||||
limit: Some(1),
|
||||
..Default::default()
|
||||
}))
|
||||
.await?;
|
||||
if out.lines.first().is_none_or(|l| l.seq != seq) {
|
||||
return Err(format!("the journal has no line {seq}"));
|
||||
}
|
||||
json(&out)
|
||||
}
|
||||
handbook::URI => {
|
||||
let Json(out) = self.handbook(Parameters(HandbookParams::default())).await?;
|
||||
json(&out)
|
||||
@@ -1704,6 +1834,8 @@ impl ServerHandler for BooksServer {
|
||||
let running_call = call.clone();
|
||||
// Kept for the record of a call the server stopped before running.
|
||||
let request_args = request.arguments.clone();
|
||||
let tool_name = request.name.to_string();
|
||||
let envelope_args = request.arguments.clone();
|
||||
let result = run_blocking(&self.store, move || {
|
||||
Box::pin(async move {
|
||||
WROTE.with(|w| w.borrow_mut().take());
|
||||
@@ -1712,6 +1844,17 @@ impl ServerHandler for BooksServer {
|
||||
.caller()
|
||||
.map(|c| (c.token.id.clone(), c.token.name.clone()));
|
||||
CALLER.with(|c| *c.borrow_mut() = caller);
|
||||
AGENT_NOTE.with(|a| a.borrow_mut().take());
|
||||
YOU.with(|y| {
|
||||
*y.borrow_mut() = running_call.caller().map(|c| {
|
||||
serde_json::json!({
|
||||
"principal": c.token.name,
|
||||
"kind": "agent",
|
||||
"scopes": c.token.scopes.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
||||
"client_id": running_call.client_name(),
|
||||
})
|
||||
})
|
||||
});
|
||||
// Counted as running by now (run_blocking): hashed here.
|
||||
let digest = Call::digest_of(request.arguments.as_ref());
|
||||
let tcc =
|
||||
@@ -1740,8 +1883,14 @@ impl ServerHandler for BooksServer {
|
||||
std::panic::resume_unwind(panic)
|
||||
}
|
||||
};
|
||||
// The result envelope (next_steps.rs INVARIANT 1).
|
||||
let out = next_steps::apply(&server, &tool_name, envelope_args.as_ref(), out);
|
||||
// Who asked is the request's alone: a later request on this
|
||||
// thread (a resource read) must not see it.
|
||||
YOU.with(|y| y.borrow_mut().take());
|
||||
let mut c = running_call;
|
||||
c.digested(digest);
|
||||
c.with_agent(AGENT_NOTE.with(|a| a.borrow_mut().take()));
|
||||
let recorded = c.record_blocking(outcome_of(&out), wrote);
|
||||
(out, recorded)
|
||||
})
|
||||
@@ -1825,6 +1974,36 @@ impl ServerHandler for BooksServer {
|
||||
Ok(ListResourcesResult::with_all_items(resource_list()))
|
||||
}
|
||||
|
||||
async fn list_resource_templates(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::ListResourceTemplatesResult, rmcp::ErrorData> {
|
||||
Ok(rmcp::model::ListResourceTemplatesResult::with_all_items(
|
||||
resource_templates(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_prompts(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::ListPromptsResult, rmcp::ErrorData> {
|
||||
prompt_reader(&context)?;
|
||||
Ok(Self::prompt_list())
|
||||
}
|
||||
|
||||
async fn get_prompt(
|
||||
&self,
|
||||
request: rmcp::model::GetPromptRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::GetPromptResponse, rmcp::ErrorData> {
|
||||
prompt_reader(&context)?;
|
||||
self.prompt_get(&request)
|
||||
.map(Into::into)
|
||||
.map_err(|e| rmcp::ErrorData::invalid_params(e, None))
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
request: ReadResourceRequestParams,
|
||||
@@ -1922,7 +2101,10 @@ impl BooksServer {
|
||||
let server = self.clone();
|
||||
let at = uri.clone();
|
||||
let body = run_blocking(&self.store, move || {
|
||||
Box::pin(async move { in_timezone_async(timezone, server.resource_body(&at)).await })
|
||||
Box::pin(async move {
|
||||
YOU.with(|y| y.borrow_mut().take());
|
||||
in_timezone_async(timezone, server.resource_body(&at)).await
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
// A refusal quotes the URI it was given: masked like a tool's.
|
||||
|
||||
@@ -9,6 +9,25 @@
|
||||
use super::*;
|
||||
use crate::server::test_support::*;
|
||||
|
||||
/// Plan 4.2 (P5 step 2): `books.start` is loaded up front by clients that
|
||||
/// defer tools, and no other tool asks for that.
|
||||
#[test]
|
||||
fn books_start_is_always_loaded() {
|
||||
for tool in BooksServer::tool_router().list_all() {
|
||||
let always = tool
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.0.get("anthropic/alwaysLoad"))
|
||||
.and_then(serde_json::Value::as_bool);
|
||||
assert_eq!(
|
||||
always == Some(true),
|
||||
tool.name == "books.start",
|
||||
"{}",
|
||||
tool.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An upload's limit, 25 MiB exactly (plan 4.7); nothing comes inline
|
||||
/// (D-46).
|
||||
#[test]
|
||||
@@ -42,7 +61,7 @@ fn annotations_complete() {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||
let table: [(&str, Hints); 133] = [
|
||||
let table: [(&str, Hints); 140] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
("books.set_setting", DI),
|
||||
@@ -178,7 +197,15 @@ fn annotations_complete() {
|
||||
("books.list_drafts", R),
|
||||
("books.set_draft_state", W),
|
||||
("books.discard_draft", D),
|
||||
("books.journal_history", R),
|
||||
("books.preview_entry", R),
|
||||
// P8: audits and attestations.
|
||||
("books.audit", W),
|
||||
("books.workpaper", R),
|
||||
("books.attest_receipt", W),
|
||||
("books.review_entry", W),
|
||||
("books.attest_period", W),
|
||||
("books.attestations", R),
|
||||
];
|
||||
let tools = BooksServer::tool_router().list_all();
|
||||
assert_eq!(tools.len(), table.len());
|
||||
@@ -701,23 +728,10 @@ fn tool_text_lint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool descriptions are at most 700 characters (plan I-12). The
|
||||
/// ones over it today are listed, each to be shortened in P5 when its
|
||||
/// text moves into the handbook; no new one may join them, and one
|
||||
/// that shrinks under the cap must leave the list.
|
||||
/// Tool descriptions are at most 700 characters (plan I-12); the long
|
||||
/// text lives in the handbook (P5 moved the last eight there).
|
||||
#[test]
|
||||
fn tool_descriptions_fit_700_characters() {
|
||||
// Shorten in P5.
|
||||
const OVER: &[&str] = &[
|
||||
"books.export",
|
||||
"books.export_cpa_package",
|
||||
"books.import_bank_csv",
|
||||
"books.reconcile",
|
||||
"books.set_entry_party",
|
||||
"books.set_w9",
|
||||
"books.show",
|
||||
"books.vendor_1099",
|
||||
];
|
||||
let mut over: Vec<String> = BooksServer::tool_router()
|
||||
.list_all()
|
||||
.iter()
|
||||
@@ -725,7 +739,10 @@ fn tool_descriptions_fit_700_characters() {
|
||||
.map(|t| t.name.to_string())
|
||||
.collect();
|
||||
over.sort();
|
||||
assert_eq!(over, OVER, "tool descriptions over 700 characters");
|
||||
assert!(
|
||||
over.is_empty(),
|
||||
"tool descriptions over 700 characters: {over:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The tools docs/mcp-interface.md documents: a line (or a bullet)
|
||||
@@ -1528,3 +1545,100 @@ fn a_tool_list_is_cached_only_for_its_caller() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Plan 4.6 (P5 step 1): the envelope is put on every result in one
|
||||
/// place; a tool that can change the books answers with the journal's
|
||||
/// `head` and at least one next step, whatever it said itself, and the
|
||||
/// text block says the same as the structured content.
|
||||
#[test]
|
||||
fn every_mutating_result_has_head_and_next_steps() {
|
||||
use rmcp::model::{CallToolResponse, CallToolResult, ContentBlock};
|
||||
let dir = unit_dir("envelope");
|
||||
let server = served(store(&dir));
|
||||
let (seq, hash) = server.lock().unwrap().head();
|
||||
let mutating = next_steps::mutating_tools();
|
||||
assert!(mutating.contains("books.post_entry") && !mutating.contains("books.trial_balance"));
|
||||
let mut read_only = 0;
|
||||
for tool in BooksServer::tool_router().list_all() {
|
||||
let name = tool.name.to_string();
|
||||
let answer = CallToolResponse::Complete(CallToolResult::structured(
|
||||
serde_json::json!({"status": "ok"}),
|
||||
));
|
||||
let CallToolResponse::Complete(r) =
|
||||
next_steps::apply(&server, &name, None, Ok(answer)).expect("an answer stays an answer")
|
||||
else {
|
||||
panic!("{name}: not a complete answer");
|
||||
};
|
||||
let body = r.structured_content.expect("structured");
|
||||
assert!(
|
||||
body.get("guards").is_none() && body.get("warnings").is_none(),
|
||||
"{name}: a success without them says none"
|
||||
);
|
||||
let ContentBlock::Text(t) = &r.content[0] else {
|
||||
panic!("{name}: no text block");
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&t.text).unwrap(),
|
||||
body,
|
||||
"{name}: the text says what the structure says"
|
||||
);
|
||||
if mutating.contains(&name) {
|
||||
assert_eq!(body["head"]["seq"], seq, "{name}");
|
||||
assert_eq!(body["head"]["hash"], hash.as_str(), "{name}");
|
||||
let steps = body["next_steps"].as_array().expect("next_steps");
|
||||
assert!(!steps.is_empty(), "{name}: no next step");
|
||||
for s in steps {
|
||||
assert!(s["why"].is_string() && s["required"].is_boolean(), "{name}");
|
||||
}
|
||||
} else {
|
||||
read_only += 1;
|
||||
assert!(body.get("head").is_none(), "{name}: a read has no head");
|
||||
}
|
||||
}
|
||||
assert!(read_only > 20);
|
||||
}
|
||||
|
||||
/// A tool's own `next_steps`, `warnings` and `head` are kept (the
|
||||
/// envelope fills only what the result lacks), and a refusal that is a
|
||||
/// guard carries it.
|
||||
#[test]
|
||||
fn the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard() {
|
||||
use rmcp::model::{CallToolResponse, CallToolResult};
|
||||
let dir = unit_dir("envelope-keeps");
|
||||
let server = served(store(&dir));
|
||||
let own = serde_json::json!({
|
||||
"next_steps": [{"tool": "books.digest", "why": "mine", "required": false}],
|
||||
"warnings": ["careful"],
|
||||
"head": {"seq": 0, "hash": "x"},
|
||||
});
|
||||
let CallToolResponse::Complete(r) = next_steps::apply(
|
||||
&server,
|
||||
"books.post_entry",
|
||||
None,
|
||||
Ok(CallToolResponse::Complete(CallToolResult::structured(
|
||||
own.clone(),
|
||||
))),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
let body = r.structured_content.unwrap();
|
||||
assert_eq!(body["next_steps"], own["next_steps"]);
|
||||
assert_eq!(body["warnings"], own["warnings"]);
|
||||
assert_eq!(body["head"], own["head"]);
|
||||
let refused = CallToolResponse::Complete(CallToolResult::error(vec![
|
||||
rmcp::model::ContentBlock::text(
|
||||
serde_json::json!({"code": "approval_required", "message": "needs approval", "remedy_tool": "books.approve_entry"})
|
||||
.to_string(),
|
||||
),
|
||||
]));
|
||||
let CallToolResponse::Complete(r) =
|
||||
next_steps::apply(&server, "books.post_entry", None, Ok(refused)).unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let g = &r.structured_content.unwrap()["guards"][0];
|
||||
assert_eq!(g["code"], "approval_required");
|
||||
assert_eq!(g["remedy_tool"], "books.approve_entry");
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
//! The audit group (plan P8; rules.md B-45): an audit run over the books,
|
||||
//! its workpaper, and the claims that someone checked.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the whole re-audit through the tools: one programme of checks
|
||||
//! that gives the same findings and the same workpaper for the same
|
||||
//! books, and attestations that rest on a head that has not moved.
|
||||
//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit
|
||||
//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53
|
||||
//! (an audit is a run, not a job), IZ-1k (the head).
|
||||
//! INVARIANTS:
|
||||
//! 1. `books.audit` previews (what it will check; nothing changes),
|
||||
//! then with the preview's digest runs the checks, files the
|
||||
//! workpaper (JSON and PDF) as documents and journals `audit.run`.
|
||||
//! 2. The same books (the figure head), day, scope and programme give
|
||||
//! the run that exists: nothing is filed or journaled twice.
|
||||
//! 3. An attestation is a claim by the caller named by its token; the
|
||||
//! method it gives is self-reported and the books say so.
|
||||
//! 4. `counted` is read against `policy.attestation_bar` as it stands
|
||||
//! when asked, never stored.
|
||||
//! 5. A period is attested only on an audit at the current head.
|
||||
//! 6. Nothing here moves a figure.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use ledger_core::audit::{self, AttestationBar, AuditInput, Effort, Finding, Method, Scope};
|
||||
use ledger_store::audit::{self as saudit, Attested, AuditRun, BackupStanding, Claim};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn sha_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
/// The caller's token name, else the default token's.
|
||||
fn caller_name() -> String {
|
||||
CALLER
|
||||
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
|
||||
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string())
|
||||
}
|
||||
|
||||
fn method_of(m: Option<AttestMethodParam>) -> Result<Method, String> {
|
||||
let m = m.unwrap_or_default();
|
||||
let reasoning_effort = match m.reasoning_effort.as_deref() {
|
||||
None => None,
|
||||
Some(e) => Some(Effort::parse(e).ok_or_else(|| {
|
||||
format!("reasoning_effort {e:?} is not low, medium, high, xhigh or max")
|
||||
})?),
|
||||
};
|
||||
if m.verifier_agents == Some(0) {
|
||||
return Err("verifier_agents is at least 1".into());
|
||||
}
|
||||
Ok(Method {
|
||||
model: m
|
||||
.model
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
reasoning_effort,
|
||||
adversarial: m.adversarial,
|
||||
verifier_agents: m.verifier_agents,
|
||||
})
|
||||
}
|
||||
|
||||
fn method_out(m: &Method) -> AttestMethodParam {
|
||||
AttestMethodParam {
|
||||
model: m.model.clone(),
|
||||
reasoning_effort: m.reasoning_effort.map(|e| e.key().to_string()),
|
||||
adversarial: m.adversarial,
|
||||
verifier_agents: m.verifier_agents,
|
||||
}
|
||||
}
|
||||
|
||||
fn finding_out(f: &Finding) -> AuditFindingOut {
|
||||
AuditFindingOut {
|
||||
severity: f.severity.key().into(),
|
||||
code: f.code.into(),
|
||||
subject: f.subject.describe(),
|
||||
message: f.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every check the programme runs.
|
||||
fn checks() -> Vec<AuditCheckOut> {
|
||||
audit::CODES
|
||||
.iter()
|
||||
.chain(saudit::CODES)
|
||||
.map(|r| AuditCheckOut {
|
||||
code: r.code.into(),
|
||||
severity: r.severity.key().into(),
|
||||
predicate: r.predicate.into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_out(store: &Store, run: &AuditRun) -> Result<AuditRunOut, String> {
|
||||
Ok(AuditRunOut {
|
||||
id: run.id,
|
||||
through: run.through.to_string(),
|
||||
through_seq: run.through_seq,
|
||||
scope: run.scope.key().into(),
|
||||
program_version: run.program_version.clone(),
|
||||
tb_hash: run.tb_hash.clone(),
|
||||
findings_sha256: run.findings_sha256.clone(),
|
||||
workpaper_attachment: run.workpaper_attachment,
|
||||
workpaper_pdf: run.workpaper_pdf,
|
||||
at_current_head: store.audit_is_current(run).map_err(err)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn summary(findings: &[Finding]) -> String {
|
||||
let n = |s: audit::Severity| findings.iter().filter(|f| f.severity == s).count();
|
||||
format!(
|
||||
"{} failure(s), {} warning(s), {} note(s)",
|
||||
n(audit::Severity::Failure),
|
||||
n(audit::Severity::Warning),
|
||||
n(audit::Severity::Info)
|
||||
)
|
||||
}
|
||||
|
||||
/// Why an attestation does not count at this bar, in words.
|
||||
fn why_not(bar: &AttestationBar, m: &Method, full: bool, disagrees: bool) -> Option<String> {
|
||||
if bar.counts(m, full, disagrees) {
|
||||
return None;
|
||||
}
|
||||
if disagrees {
|
||||
return Some(
|
||||
"the amount or date read off the receipt differs from the entry's: it is recorded and never counts"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut why = Vec::new();
|
||||
if !m
|
||||
.reasoning_effort
|
||||
.is_some_and(|e| e >= bar.reasoning_effort)
|
||||
{
|
||||
why.push(format!(
|
||||
"reasoning_effort must be at least {}",
|
||||
bar.reasoning_effort.key()
|
||||
));
|
||||
}
|
||||
if full {
|
||||
if bar.adversarial && m.adversarial != Some(true) {
|
||||
why.push("the check must be adversarial".into());
|
||||
}
|
||||
if !m.verifier_agents.is_some_and(|n| n >= bar.verifier_agents) {
|
||||
why.push(format!(
|
||||
"at least {} independent verifier agents",
|
||||
bar.verifier_agents
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(format!(
|
||||
"below policy.attestation_bar ({}): {}",
|
||||
bar.describe(),
|
||||
why.join("; ")
|
||||
))
|
||||
}
|
||||
|
||||
/// An attestation as the tools list it.
|
||||
fn attestation_out(
|
||||
store: &Store,
|
||||
a: &saudit::Attestation,
|
||||
bar: &AttestationBar,
|
||||
) -> Result<AttestationOut, String> {
|
||||
let mut out = AttestationOut {
|
||||
seq: a.seq,
|
||||
kind: a.kind().into(),
|
||||
by: a.by.clone(),
|
||||
at: a.at.clone(),
|
||||
attachment_id: None,
|
||||
entry_id: None,
|
||||
period_id: None,
|
||||
audit_run_id: None,
|
||||
amount_checked: None,
|
||||
date_checked: None,
|
||||
disagrees: false,
|
||||
method: method_out(&a.method),
|
||||
note: a.note.clone(),
|
||||
counted: false,
|
||||
};
|
||||
let (full, disagrees) = match &a.what {
|
||||
Attested::Receipt {
|
||||
attachment_id,
|
||||
entry_id,
|
||||
amount_checked,
|
||||
date_checked,
|
||||
disagrees,
|
||||
} => {
|
||||
out.attachment_id = Some(*attachment_id);
|
||||
out.entry_id = Some(*entry_id);
|
||||
out.amount_checked = Some(amount_checked.to_string());
|
||||
out.date_checked = Some(date_checked.to_string());
|
||||
out.disagrees = *disagrees;
|
||||
(false, *disagrees)
|
||||
}
|
||||
Attested::Entry { entry_id } => {
|
||||
out.entry_id = Some(*entry_id);
|
||||
(false, false)
|
||||
}
|
||||
Attested::Period {
|
||||
period_id,
|
||||
audit_run_id,
|
||||
} => {
|
||||
out.period_id = Some(*period_id);
|
||||
out.audit_run_id = Some(*audit_run_id);
|
||||
let scope = store
|
||||
.audit_run(*audit_run_id)
|
||||
.map_err(err)?
|
||||
.map(|r| r.scope);
|
||||
(scope.is_some_and(Scope::full_bar), false)
|
||||
}
|
||||
};
|
||||
out.counted = bar.counts(&a.method, full, disagrees);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What an attestation's tool answers.
|
||||
fn attest_out(store: &Store, a: &saudit::Attestation) -> Result<AttestOut, String> {
|
||||
let bar = store.ledger().settings().attestation_bar();
|
||||
let o = attestation_out(store, a, &bar)?;
|
||||
let full = o
|
||||
.audit_run_id
|
||||
.and_then(|id| store.audit_run(id).ok().flatten())
|
||||
.is_some_and(|r| r.scope.full_bar());
|
||||
let why_not_counted = why_not(&bar, &a.method, full, o.disagrees);
|
||||
Ok(AttestOut {
|
||||
attestation: o,
|
||||
bar: bar.describe(),
|
||||
why_not_counted,
|
||||
})
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// Whether the CPA package may be made (plan P8 step 4, D-9): on books
|
||||
/// that declared `audits` and a policy of refuse, an audit for the
|
||||
/// handoff (scope cpa_handoff or full) at the current head must have a
|
||||
/// period attested on it at the bar. `Ok(None)` when it may; the
|
||||
/// refusal's words otherwise.
|
||||
pub(crate) fn cpa_audit_standing(&self, store: &Store) -> Result<Option<String>, String> {
|
||||
if !store
|
||||
.journal_format()
|
||||
.has_feature(ledger_store::format::AUDITS)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let settings = store.ledger().settings();
|
||||
let bar = settings.attestation_bar();
|
||||
let attestations = store.attestations().map_err(err)?;
|
||||
for run in store.audit_runs().map_err(err)?.iter().rev() {
|
||||
if !run.scope.serves_handoff() || !store.audit_is_current(run).map_err(err)? {
|
||||
continue;
|
||||
}
|
||||
let counted = attestations.iter().any(|a| {
|
||||
matches!(a.what, Attested::Period { audit_run_id, .. } if audit_run_id == run.id)
|
||||
&& bar.counts(&a.method, run.scope.full_bar(), false)
|
||||
});
|
||||
if counted {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(Some(format!(
|
||||
"no audit for the handoff (books.audit with scope cpa_handoff or full) stands at the books' current head with a period attested at the bar ({}); run the audit, have it verified (books.attest_receipt, books.review_entry, books.attest_period), or pass without_audit_reason",
|
||||
bar.describe()
|
||||
)))
|
||||
}
|
||||
|
||||
/// The findings the programme gives the books through `through`: the
|
||||
/// statements, then what only the store, the backups and the settings
|
||||
/// can see. Reads only.
|
||||
fn audit_findings(
|
||||
&self,
|
||||
store: &Store,
|
||||
through: NaiveDate,
|
||||
) -> Result<(Vec<Finding>, String), String> {
|
||||
let ledger = store.ledger();
|
||||
let cfg = self.settings();
|
||||
let ps = package_settings(&cfg);
|
||||
let receipts = store.entries_with_receipts().map_err(err)?;
|
||||
// The statement of cash flows, from the first entry, with the
|
||||
// books' role settings; none when they name no cash accounts.
|
||||
let first = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.date() <= through)
|
||||
.map(|e| e.date())
|
||||
.min();
|
||||
let cash = match first {
|
||||
Some(start) if !ps.cash_accounts.is_empty() => Some(
|
||||
ledger_package::cash_flow_for(
|
||||
ledger,
|
||||
&ps,
|
||||
Some(&start.to_string()),
|
||||
Some(&through.to_string()),
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
today(),
|
||||
)
|
||||
.map(|s| s.reconciles),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
// Vendors paid in a way that could need a 1099 and undecided; a
|
||||
// year whose threshold isn't known is the stale-rule item's.
|
||||
use chrono::Datelike;
|
||||
let undecided: Vec<(String, rust_decimal::Decimal)> =
|
||||
ledger_package::vendor_1099_report(ledger, &ps, through.year(), None, Some(through))
|
||||
.map(|r| {
|
||||
r.vendors
|
||||
.iter()
|
||||
.filter(|v| v.decision_needed)
|
||||
.map(|v| (v.name.clone(), v.paid))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let problems = settings_problems(ledger, &cfg);
|
||||
let mut findings = audit::statements(
|
||||
ledger,
|
||||
&AuditInput {
|
||||
settings: ledger.settings(),
|
||||
receipts: &receipts,
|
||||
cash_flow: cash.as_ref(),
|
||||
settings_problems: &problems,
|
||||
undecided_1099: &undecided,
|
||||
},
|
||||
through,
|
||||
);
|
||||
findings.extend(store.integrity_findings());
|
||||
let standing = self.backups.standing(store);
|
||||
findings.extend(saudit::backup_findings(
|
||||
&standing
|
||||
.targets
|
||||
.iter()
|
||||
.map(|t| BackupStanding {
|
||||
target: format!("{} {}", t.target, t.at),
|
||||
stale: t.stale,
|
||||
last_error: t.last_error.clone(),
|
||||
drill_error: t.drill_error.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
));
|
||||
audit::sort(&mut findings);
|
||||
let tb = ledger_core::reports::trial_balance_as_of(ledger, through);
|
||||
let tb_hash = sha_hex(&serde_json::to_vec(&tb).map_err(err)?);
|
||||
Ok((findings, tb_hash))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = audit_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
/// ```text
|
||||
/// GOAL: the whole programme of checks over the books through a day,
|
||||
/// its workpaper filed, the run journaled.
|
||||
/// SOURCE: plan/served.md P8; B-45; D-9, D-53.
|
||||
/// INVARIANTS:
|
||||
/// 1. Without `confirmed`, a preview that lists the checks and
|
||||
/// changes nothing; with it, the preview's digest is required.
|
||||
/// 2. Idempotent on the figure head, day, scope and programme.
|
||||
/// 3. The workpaper is JSON and PDF, filed as documents (category
|
||||
/// workpaper), the PDF with deterministic bytes.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the books haven't declared audits: the run is refused with
|
||||
/// format_upgrade_pending; the preview still lists the checks.
|
||||
/// 2. IF the same audit is asked at the same head: SHOULD return
|
||||
/// the first run, status already_run.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.audit",
|
||||
title = "Audit the books",
|
||||
description = "Audit the books through a day (through, default today) at a scope: month, annual, cpa_handoff (the year, for the CPA package) or full. It checks the statements, the A/R and A/P control accounts, closes, schedules, assets, each posted entry against the receipt policy, open drafts, tax categories, 1099 decisions and settings, then the journal, stored files, access log and backups. Without confirmed, a preview of the checks, changing nothing. With confirmed and the preview, it files the workpaper (books.workpaper) and journals the run; the same books, day and scope give the run that exists. Attestations rest on a run only while no entry, document, close, settings or role change follows it",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn audit(&self, params: Parameters<AuditParams>) -> Result<Json<AuditOut>, String> {
|
||||
let p = params.0;
|
||||
let scope = Scope::parse(&p.scope).ok_or_else(|| {
|
||||
format!(
|
||||
"bad scope {:?}: expected month, annual, cpa_handoff or full",
|
||||
p.scope
|
||||
)
|
||||
})?;
|
||||
let through = self.as_of(p.through.as_deref())?;
|
||||
let confirmed = p.confirmed.unwrap_or(false);
|
||||
let mut store = self.lock()?;
|
||||
let head = store.figure_head().map_err(err)?;
|
||||
let digest = self.preview_of(&store, "books.audit", confirmed, p.preview.as_deref())?;
|
||||
if let Some(run) = store.find_audit_run(through, head, scope).map_err(err)? {
|
||||
let findings = self.stored_findings(&store, &run)?;
|
||||
return Ok(Json(AuditOut {
|
||||
status: "already_run".into(),
|
||||
preview: Some(digest),
|
||||
head,
|
||||
checks: checks(),
|
||||
summary: Some(summary_of(&findings)),
|
||||
run: Some(run_out(&store, &run)?),
|
||||
findings,
|
||||
}));
|
||||
}
|
||||
if !confirmed {
|
||||
return Ok(Json(AuditOut {
|
||||
status: "preview".into(),
|
||||
preview: Some(digest),
|
||||
head,
|
||||
checks: checks(),
|
||||
run: None,
|
||||
findings: Vec::new(),
|
||||
summary: None,
|
||||
}));
|
||||
}
|
||||
store
|
||||
.ensure_feature(ledger_store::format::AUDITS)
|
||||
.map_err(err)?;
|
||||
let (findings, tb_hash) = self.audit_findings(&store, through)?;
|
||||
let findings_sha256 = sha_hex(&serde_json::to_vec(&findings).map_err(err)?);
|
||||
let summary = summary(&findings);
|
||||
let company = self.settings().company.name.clone();
|
||||
let rows: Vec<AuditFindingOut> = findings.iter().map(finding_out).collect();
|
||||
let check_rows = checks();
|
||||
let paper = serde_json::json!({
|
||||
"company": company,
|
||||
"title": "Audit workpaper",
|
||||
"scope": scope.key(),
|
||||
"through": through.to_string(),
|
||||
"through_seq": head,
|
||||
"program_version": saudit::PROGRAM_VERSION,
|
||||
"tb_hash": tb_hash,
|
||||
"findings_sha256": findings_sha256,
|
||||
"summary": summary,
|
||||
"findings": rows,
|
||||
"checks": check_rows,
|
||||
});
|
||||
let json_bytes = serde_json::to_vec_pretty(&paper).map_err(err)?;
|
||||
let pdf_bytes = ledger_import::workpaper_pdf(&ledger_import::Workpaper {
|
||||
company,
|
||||
title: "Audit workpaper".into(),
|
||||
scope: scope.key().into(),
|
||||
through: through.to_string(),
|
||||
through_seq: head,
|
||||
program_version: saudit::PROGRAM_VERSION.into(),
|
||||
tb_hash: tb_hash.clone(),
|
||||
findings_sha256: findings_sha256.clone(),
|
||||
summary: summary.clone(),
|
||||
findings: rows
|
||||
.iter()
|
||||
.map(|f| ledger_import::WorkpaperFinding {
|
||||
severity: f.severity.clone(),
|
||||
code: f.code.clone(),
|
||||
subject: f.subject.clone(),
|
||||
message: f.message.clone(),
|
||||
})
|
||||
.collect(),
|
||||
checks: check_rows
|
||||
.iter()
|
||||
.map(|c| ledger_import::WorkpaperCheck {
|
||||
code: c.code.clone(),
|
||||
severity: c.severity.clone(),
|
||||
predicate: c.predicate.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.map_err(err)?;
|
||||
let stem = format!("workpaper-{}-{through}-{head}", scope.key());
|
||||
let describe = format!(
|
||||
"Audit workpaper, scope {}, through {through}, books at figure head {head} ({}).",
|
||||
scope.key(),
|
||||
saudit::PROGRAM_VERSION
|
||||
);
|
||||
let (wp_json, _, _) = store
|
||||
.file_document(
|
||||
"workpaper",
|
||||
&format!("{stem}.json"),
|
||||
"application/json",
|
||||
&json_bytes,
|
||||
Some(&describe),
|
||||
)
|
||||
.map_err(err)?;
|
||||
let (wp_pdf, _, _) = store
|
||||
.file_document(
|
||||
"workpaper",
|
||||
&format!("{stem}.pdf"),
|
||||
"application/pdf",
|
||||
&pdf_bytes,
|
||||
Some(&describe),
|
||||
)
|
||||
.map_err(err)?;
|
||||
// A month's or a year's workpaper belongs with its period.
|
||||
if matches!(scope, Scope::Month | Scope::Annual)
|
||||
&& let Some(period) = store
|
||||
.ledger()
|
||||
.list_periods()
|
||||
.into_iter()
|
||||
.filter(|q| q.start <= through && through <= q.end)
|
||||
.min_by_key(|q| (q.end - q.start).num_days())
|
||||
.map(|q| q.id)
|
||||
{
|
||||
for a in [wp_json, wp_pdf] {
|
||||
let _ = store.link_attachment(a, ledger_store::Subject::Period(period));
|
||||
}
|
||||
}
|
||||
let run = store
|
||||
.record_audit_run(saudit::NewAuditRun {
|
||||
through,
|
||||
through_seq: head,
|
||||
scope,
|
||||
tb_hash,
|
||||
findings_sha256,
|
||||
workpaper_attachment: Some(wp_json),
|
||||
workpaper_pdf: Some(wp_pdf),
|
||||
})
|
||||
.map_err(err)?;
|
||||
Ok(Json(AuditOut {
|
||||
status: "audited".into(),
|
||||
preview: Some(digest),
|
||||
head,
|
||||
checks: check_rows,
|
||||
summary: Some(summary),
|
||||
run: Some(run_out(&store, &run)?),
|
||||
findings: rows,
|
||||
}))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: an audit run's workpaper, whole.
|
||||
/// SOURCE: plan/served.md P8 step 3; B-45.
|
||||
/// INVARIANTS:
|
||||
/// 1. Read-only; the JSON is the one filed, the PDF is handed over.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.workpaper",
|
||||
title = "An audit's workpaper",
|
||||
description = "The workpaper of an audit run (books.audit's run.id): what was checked, through which day and state of the books, and every finding, as the JSON that was filed, with the PDF handed over (run its download command to save it in a folder). Changes nothing.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn workpaper(
|
||||
&self,
|
||||
params: Parameters<WorkpaperParams>,
|
||||
) -> Result<Json<WorkpaperOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let run = store.audit_run(params.0.id).map_err(err)?.ok_or_else(|| {
|
||||
format!(
|
||||
"no audit run {}: books.attestations lists the ones attested",
|
||||
params.0.id
|
||||
)
|
||||
})?;
|
||||
let json_id = run
|
||||
.workpaper_attachment
|
||||
.ok_or("this audit run filed no workpaper")?;
|
||||
let bytes = store.attachment_bytes(json_id).map_err(err)?;
|
||||
let workpaper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?;
|
||||
let pdf = match run.workpaper_pdf {
|
||||
Some(id) => {
|
||||
let bytes = store.attachment_bytes(id).map_err(err)?;
|
||||
Some(self.hand_over(
|
||||
&book_name(&store),
|
||||
&format!(
|
||||
"workpaper-{}-{}-{}.pdf",
|
||||
run.scope.key(),
|
||||
run.through,
|
||||
run.through_seq
|
||||
),
|
||||
"application/pdf",
|
||||
bytes,
|
||||
)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(Json(WorkpaperOut {
|
||||
run: run_out(&store, &run)?,
|
||||
workpaper,
|
||||
pdf,
|
||||
}))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: a checker's claim that a receipt supports an entry.
|
||||
/// SOURCE: plan/served.md P8 step 4; B-45; D-9.
|
||||
/// INVARIANTS:
|
||||
/// 1. The reading is compared with the entry's amount and date; a
|
||||
/// difference is recorded, marked, and never counts.
|
||||
/// 2. The method is the client's claim, self-reported.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.attest_receipt",
|
||||
title = "Attest a receipt",
|
||||
description = "Record that you read a receipt and it supports a posted entry: amount and date as you read them off it (get_receipt text, then page_image), and how you checked (method: model, reasoning_effort, adversarial, verifier_agents; your own claim, which the books record but cannot verify). The books compare the reading with the entry's amounts and date; a difference is recorded as disagrees and never counts. Whether it counts is weighed against policy.attestation_bar when asked.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn attest_receipt(
|
||||
&self,
|
||||
params: Parameters<AttestReceiptParams>,
|
||||
) -> Result<Json<AttestOut>, String> {
|
||||
let p = params.0;
|
||||
let amount = parse_amount(&p.amount)?;
|
||||
let date = NaiveDate::parse_from_str(p.date.trim(), "%Y-%m-%d")
|
||||
.map_err(|_| format!("{:?} isn't a date: give YYYY-MM-DD", p.date))?;
|
||||
let claim = Claim {
|
||||
by: caller_name(),
|
||||
method: method_of(p.method)?,
|
||||
note: p.note,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let a = store
|
||||
.attest_receipt(p.attachment_id, p.entry_id, amount, date, claim)
|
||||
.map_err(err)?;
|
||||
Ok(Json(attest_out(&store, &a)?))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: a checker's claim that it reviewed an entry whole.
|
||||
/// SOURCE: plan/served.md P8 step 4; B-45.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.review_entry",
|
||||
title = "Review an entry",
|
||||
description = "Record that you reviewed a posted entry whole: its lines, accounts, party and evidence, and how you checked (method, as for books.attest_receipt; your own claim). Changes no figure.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn review_entry(
|
||||
&self,
|
||||
params: Parameters<ReviewEntryParams>,
|
||||
) -> Result<Json<AttestOut>, String> {
|
||||
let p = params.0;
|
||||
let claim = Claim {
|
||||
by: caller_name(),
|
||||
method: method_of(p.method)?,
|
||||
note: p.note,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let a = store.review_entry(p.entry_id, claim).map_err(err)?;
|
||||
Ok(Json(attest_out(&store, &a)?))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: a checker's claim that a period is right, resting on an
|
||||
/// audit run at the books' current head.
|
||||
/// SOURCE: plan/served.md P8 step 4; B-45; D-9; IZ-1k.
|
||||
/// INVARIANTS:
|
||||
/// 1. Refused unless an audit through the period's end stands at
|
||||
/// the current head.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.attest_period",
|
||||
title = "Attest a period",
|
||||
description = "Record that you checked a period (its id or name from books.list_periods), resting on an audit (books.audit) through its end that still stands: refused when an entry, document, close, settings or role change has been made since the audit, or no audit covers it. Say how you checked (method, as for books.attest_receipt; your own claim). A year-level audit (annual, cpa_handoff, full) is held to the whole policy.attestation_bar: reasoning effort, adversarial, and the number of independent verifier agents.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn attest_period(
|
||||
&self,
|
||||
params: Parameters<AttestPeriodParams>,
|
||||
) -> Result<Json<AttestOut>, String> {
|
||||
let p = params.0;
|
||||
let claim = Claim {
|
||||
by: caller_name(),
|
||||
method: method_of(p.method)?,
|
||||
note: p.note,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let named = p.period.trim();
|
||||
let period = store
|
||||
.ledger()
|
||||
.list_periods()
|
||||
.into_iter()
|
||||
.find(|q| q.name == named || q.id.to_string() == named)
|
||||
.map(|q| q.id)
|
||||
.ok_or_else(|| format!("no period {named:?}: books.list_periods lists them"))?;
|
||||
let (a, _) = store.attest_period(period, claim).map_err(|e| match e {
|
||||
ledger_store::StoreError::AuditRefused(why) => serde_json::json!({
|
||||
"code": "audit_required",
|
||||
"message": why,
|
||||
"remedy_tool": "books.audit",
|
||||
})
|
||||
.to_string(),
|
||||
other => other.to_string(),
|
||||
})?;
|
||||
Ok(Json(attest_out(&store, &a)?))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: every claim that someone checked, and whether it counts.
|
||||
/// SOURCE: plan/served.md P8 step 4; B-45; D-9.
|
||||
/// INVARIANTS:
|
||||
/// 1. `counted` is read against the bar as it stands now.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.attestations",
|
||||
title = "Attestations",
|
||||
description = "List the attestations (receipts read, entries reviewed, periods attested), oldest first, each with who made it, how they say they checked, and counted: whether it meets policy.attestation_bar as the bar stands now (a change to the bar changes what counts, earlier claims too). through limits to entries dated, and periods ending, on or before a day; kind to receipt, entry or period. Changes nothing.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn attestations(
|
||||
&self,
|
||||
params: Parameters<AttestationsParams>,
|
||||
) -> Result<Json<AttestationsOut>, String> {
|
||||
let p = params.0;
|
||||
let through = p
|
||||
.through
|
||||
.as_deref()
|
||||
.map(|t| self.as_of(Some(t)))
|
||||
.transpose()?;
|
||||
if let Some(k) = p.kind.as_deref()
|
||||
&& !["receipt", "entry", "period"].contains(&k)
|
||||
{
|
||||
return Err("kind is receipt, entry or period".into());
|
||||
}
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let bar = ledger.settings().attestation_bar();
|
||||
let mut out = Vec::new();
|
||||
for a in store.attestations().map_err(err)? {
|
||||
if p.kind.as_deref().is_some_and(|k| k != a.kind()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = through {
|
||||
let on = match a.what {
|
||||
Attested::Receipt { entry_id, .. } | Attested::Entry { entry_id } => {
|
||||
ledger.get_entry(entry_id).map(|e| e.date())
|
||||
}
|
||||
Attested::Period { period_id, .. } => {
|
||||
ledger.get_period(period_id).map(|q| q.end)
|
||||
}
|
||||
};
|
||||
if on.is_none_or(|d| d > t) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(attestation_out(&store, &a, &bar)?);
|
||||
}
|
||||
Ok(Json(AttestationsOut {
|
||||
bar: bar.describe(),
|
||||
counted: out.iter().filter(|a| a.counted).count(),
|
||||
attestations: out,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// The findings of a run, as its filed workpaper holds them.
|
||||
fn stored_findings(
|
||||
&self,
|
||||
store: &Store,
|
||||
run: &AuditRun,
|
||||
) -> Result<Vec<AuditFindingOut>, String> {
|
||||
let Some(id) = run.workpaper_attachment else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let bytes = store.attachment_bytes(id).map_err(err)?;
|
||||
let paper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?;
|
||||
serde_json::from_value(paper["findings"].clone()).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_of(findings: &[AuditFindingOut]) -> String {
|
||||
let n = |s: &str| findings.iter().filter(|f| f.severity == s).count();
|
||||
format!(
|
||||
"{} failure(s), {} warning(s), {} note(s)",
|
||||
n("failure"),
|
||||
n("warning"),
|
||||
n("info")
|
||||
)
|
||||
}
|
||||
@@ -455,17 +455,7 @@ impl BooksServer {
|
||||
) -> Result<Json<InvoiceOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let customer = match (&p.customer, p.party_id) {
|
||||
(Some(c), None) => c.trim().to_string(),
|
||||
(None, Some(id)) => id.to_string(),
|
||||
(Some(c), Some(id)) if c.trim() == id.to_string() => c.trim().to_string(),
|
||||
(Some(_), Some(_)) => {
|
||||
return Err("give the customer once: customer or party_id, not both".into());
|
||||
}
|
||||
(None, None) => {
|
||||
return Err("give customer: a customer id, a party id or a name".into());
|
||||
}
|
||||
};
|
||||
let customer = p.customer.trim().to_string();
|
||||
let customer_id = resolve_customer(&store, &customer)?;
|
||||
let date = parse_date(&p.date)?;
|
||||
let lines = document_lines(&store, p.lines)?;
|
||||
@@ -606,17 +596,7 @@ impl BooksServer {
|
||||
) -> Result<Json<BillOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let vendor = match (&p.vendor, p.party_id) {
|
||||
(Some(v), None) => v.trim().to_string(),
|
||||
(None, Some(id)) => id.to_string(),
|
||||
(Some(v), Some(id)) if v.trim() == id.to_string() => v.trim().to_string(),
|
||||
(Some(_), Some(_)) => {
|
||||
return Err("give the vendor once: vendor or party_id, not both".into());
|
||||
}
|
||||
(None, None) => {
|
||||
return Err("give vendor: a vendor id, a party id or a name".into());
|
||||
}
|
||||
};
|
||||
let vendor = p.vendor.trim().to_string();
|
||||
let vendor_id = resolve_vendor_arg(&store, &vendor)?;
|
||||
let date = parse_date(&p.date)?;
|
||||
let received_on = p.received_on.as_deref().map(parse_date).transpose()?;
|
||||
|
||||
@@ -706,7 +706,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.import_bank_csv",
|
||||
title = "Import a bank CSV as drafts",
|
||||
description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).",
|
||||
description = "Import a bank or card CSV (Date, Description, Amount; or Date, Description, money out, money in) as one DRAFT entry per transaction, all or none: the bank account on one side, a categorized offset on the other. Give the CSV as upload_id (books.begin_upload purpose import; a file never passes through you). Ambiguous slash dates need date_order. Review with books.digest, then post. Also file the export (books.file_document category import_source) and pass source_attachment_id. Details in books.handbook section files-in-out.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! `books.journal_history`: the journal in words (plan P5 step 4).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent can trace what the books did, line by line, without the
|
||||
//! journal file: the only way to "read the journal".
|
||||
//! SOURCE: plan/served.md 4.3 (tracing figures), P5 step 4; the describer
|
||||
//! is ledger_store::describe.
|
||||
//! INVARIANTS:
|
||||
//! 1. Read-only; needs books:read; at most 200 lines a call, 50 by
|
||||
//! default, in journal order from the line after `after_seq`.
|
||||
//! 2. A sentence quotes no setting value and no full TIN
|
||||
//! (ledger_store::describe INVARIANT 2).
|
||||
//! 3. `subject` is `kind:id`; a bad one is refused with the shapes.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use ledger_store::describe::{HistoryFilter, parse_subject};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The most lines one call returns.
|
||||
const HISTORY_MAX: usize = 200;
|
||||
/// The lines a call returns when it names no limit.
|
||||
const HISTORY_DEFAULT: usize = 50;
|
||||
|
||||
/// Parameters for `books.journal_history`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct JournalHistoryParams {
|
||||
/// Only lines after this journal seq (default: from the first).
|
||||
#[serde(default)]
|
||||
pub after_seq: Option<u64>,
|
||||
/// Only these line types: an exact type (`entry.posted`) or a group
|
||||
/// ending in a dot (`entry.`).
|
||||
#[serde(default)]
|
||||
pub types: Vec<String>,
|
||||
/// Only lines about one subject: `entry:12`, `invoice:3`, `bill:3`,
|
||||
/// `account:10`, `party:5` or `asset:2`.
|
||||
#[serde(default)]
|
||||
pub subject: Option<String>,
|
||||
/// At most this many lines (default 50, most 200).
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// One journal line, in words.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct JournalLineOut {
|
||||
/// Its seq.
|
||||
pub seq: u64,
|
||||
/// When it was written (UTC).
|
||||
pub ts: String,
|
||||
/// Its type, e.g. `entry.posted`.
|
||||
#[serde(rename = "type")]
|
||||
pub line_type: String,
|
||||
/// What it did, in a sentence.
|
||||
pub summary: String,
|
||||
/// What it is about, as `kind:id`, if it is about one thing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<String>,
|
||||
/// The entry it belongs to, if it is about one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// A torn journal tail set aside when the books opened.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TailRepairOut {
|
||||
/// When (UTC).
|
||||
pub at: String,
|
||||
/// How many bytes were set aside.
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// Result of `books.journal_history`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct JournalHistoryOut {
|
||||
/// The lines, in journal order.
|
||||
pub lines: Vec<JournalLineOut>,
|
||||
/// Whether more lines match after the last (ask again with its seq as
|
||||
/// after_seq).
|
||||
pub more: bool,
|
||||
/// The journal's last seq.
|
||||
pub head_seq: u64,
|
||||
/// Torn tails set aside on open, oldest first.
|
||||
pub tail_repairs: Vec<TailRepairOut>,
|
||||
}
|
||||
|
||||
#[tool_router(router = journal_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.journal_history",
|
||||
title = "Journal history",
|
||||
description = "The journal in words, in order: each line's seq, time, type and a sentence saying what it did, to what, and the entry it belongs to. Filter by after_seq, types (an exact type like entry.posted, or a group like entry.) and subject (entry:12, invoice:3, bill:3, account:10, party:5, asset:2). The way to trace a figure back to what produced it, and the only way to see what the journal holds. Read-only; at most 200 lines a call.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn journal_history(
|
||||
&self,
|
||||
params: Parameters<JournalHistoryParams>,
|
||||
) -> Result<Json<JournalHistoryOut>, String> {
|
||||
let p = params.0;
|
||||
let subject = p
|
||||
.subject
|
||||
.as_deref()
|
||||
.map(|s| parse_subject(s).map(|(k, id)| format!("{k}:{id}")))
|
||||
.transpose()?;
|
||||
let limit = (p.limit.unwrap_or(HISTORY_DEFAULT as u32) as usize).clamp(1, HISTORY_MAX);
|
||||
let filter = HistoryFilter {
|
||||
after_seq: p.after_seq.unwrap_or(0),
|
||||
types: p.types,
|
||||
subject,
|
||||
limit,
|
||||
};
|
||||
let store = self.lock()?;
|
||||
let h = store.journal_history(&filter).map_err(|e| e.to_string())?;
|
||||
Ok(Json(JournalHistoryOut {
|
||||
lines: h
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|d| JournalLineOut {
|
||||
seq: d.seq,
|
||||
ts: d.ts,
|
||||
line_type: d.line_type,
|
||||
summary: d.summary,
|
||||
subject: d.subjects.first().cloned(),
|
||||
entry_id: d.entry_id,
|
||||
})
|
||||
.collect(),
|
||||
more: h.more,
|
||||
head_seq: h.head_seq,
|
||||
tail_repairs: h
|
||||
.tail_repairs
|
||||
.into_iter()
|
||||
.map(|(at, bytes)| TailRepairOut { at, bytes })
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -94,11 +94,35 @@ pub struct StartParams {
|
||||
/// (shapes next_steps). Default: daily.
|
||||
#[serde(default)]
|
||||
pub focus: Option<String>,
|
||||
/// What the agent says about itself: its model, reasoning effort and
|
||||
/// harness. Self-reported and never checked; written to the access
|
||||
/// log beside the call and used for nothing else.
|
||||
#[serde(default)]
|
||||
pub agent: Option<AgentIn>,
|
||||
}
|
||||
|
||||
/// What an agent says about itself (`books.start`'s `agent`).
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct AgentIn {
|
||||
/// The model's name ("claude-opus-4").
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
/// The reasoning effort it runs at ("high").
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// The program driving it ("claude-code").
|
||||
#[serde(default)]
|
||||
pub harness: Option<String>,
|
||||
}
|
||||
|
||||
/// The books, in brief.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StartBookOut {
|
||||
/// The books' id on this server.
|
||||
pub id: String,
|
||||
/// Where these books answer on this server (`/b/ID/mcp`): never a
|
||||
/// path on any disk.
|
||||
pub url: String,
|
||||
/// The company's name.
|
||||
pub name: String,
|
||||
/// Its time zone (IANA), or null: the computer's.
|
||||
@@ -222,12 +246,115 @@ pub struct FormatUpgradeOut {
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct NextStepOut {
|
||||
/// The tool to call.
|
||||
pub tool: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool: Option<String>,
|
||||
/// Or the prompt to run.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<String>,
|
||||
/// Its arguments, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub args: Option<Value>,
|
||||
/// Why.
|
||||
pub why: String,
|
||||
/// Whether the work is not done without it.
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// Who is asking, as the books know them (the token's own facts).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct YouOut {
|
||||
/// The token's name.
|
||||
pub principal: String,
|
||||
/// Always `agent` here.
|
||||
pub kind: String,
|
||||
/// What the token may do: books:read, books:write, books:post,
|
||||
/// books:admin.
|
||||
pub scopes: Vec<String>,
|
||||
/// The client program, as it named itself.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A draft waiting on its evidence.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StartDraftOut {
|
||||
/// The draft entry.
|
||||
pub entry_id: u64,
|
||||
/// Its date.
|
||||
pub date: String,
|
||||
/// Its total debits.
|
||||
pub amount: String,
|
||||
/// What it waits for.
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// The open drafts.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StartDraftsOut {
|
||||
/// Drafts being built.
|
||||
pub building: u32,
|
||||
/// Drafts finished and waiting for their documents (books.list_drafts
|
||||
/// has every one).
|
||||
pub awaiting_evidence: Vec<StartDraftOut>,
|
||||
}
|
||||
|
||||
/// Where reconciliation stands.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StartReconciliationOut {
|
||||
/// The accounts reconciled to statements (codes).
|
||||
pub tracked: Vec<String>,
|
||||
/// Of those, never reconciled.
|
||||
pub never_reconciled: Vec<String>,
|
||||
/// Of those, reconciled but behind.
|
||||
pub behind: Vec<String>,
|
||||
}
|
||||
|
||||
/// Where the books' backups stand.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StartBackupOut {
|
||||
/// When the latest backup took (UTC), if any did.
|
||||
pub last_at: Option<String>,
|
||||
/// The journal seq the latest holds.
|
||||
pub through_seq: Option<u64>,
|
||||
/// Whether one is stale or failing, or none is set up.
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// A prompt, in brief.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StartPromptOut {
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
/// The arguments it takes.
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// One thing the books need.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AttentionBriefOut {
|
||||
/// What it is, by kind and subject.
|
||||
pub id: String,
|
||||
/// problem, todo or info.
|
||||
pub severity: String,
|
||||
/// Its kind (`missing_receipt`, `reconcile`, ...).
|
||||
pub kind: String,
|
||||
/// What is wrong, in a line.
|
||||
pub title: String,
|
||||
/// The tool that starts the fix, if there is one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool: Option<String>,
|
||||
/// Its arguments, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub args: Option<Value>,
|
||||
}
|
||||
|
||||
/// What needs attention: the most urgent, and how many of each kind.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct NeedsAttentionOut {
|
||||
/// The most urgent (problems first), at most 12.
|
||||
pub items: Vec<AttentionBriefOut>,
|
||||
/// Every item by kind.
|
||||
pub counts: std::collections::BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// Result of `books.start`.
|
||||
@@ -264,6 +391,19 @@ pub struct StartOut {
|
||||
/// the owner gives the go-ahead. No tool can: tell the human.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format_upgrade: Option<FormatUpgradeOut>,
|
||||
/// Who is asking.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub you: Option<YouOut>,
|
||||
/// The drafts open.
|
||||
pub drafts: StartDraftsOut,
|
||||
/// Where reconciliation stands.
|
||||
pub reconciliation: StartReconciliationOut,
|
||||
/// Where the backups stand.
|
||||
pub backup: StartBackupOut,
|
||||
/// The prompts: whole procedures (`prompts/get`).
|
||||
pub prompts: Vec<StartPromptOut>,
|
||||
/// What needs attention, most urgent first.
|
||||
pub needs_attention: NeedsAttentionOut,
|
||||
/// What to do next, in order.
|
||||
pub next_steps: Vec<NextStepOut>,
|
||||
/// How the books are kept from agents: "same_user (advisory)" locally
|
||||
@@ -392,6 +532,11 @@ pub struct SetConventionParams {
|
||||
/// amount_over?}. Other kinds take none.
|
||||
#[serde(default)]
|
||||
pub structured: Option<Value>,
|
||||
/// Only to correct who decided it, when or on what (the text exactly
|
||||
/// as it stands): why the source is wrong. The correction is journaled
|
||||
/// as such, the convention's revision unchanged.
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// Who decided, and when.
|
||||
#[serde(flatten)]
|
||||
pub source: SourceIn,
|
||||
@@ -412,9 +557,12 @@ pub struct RetireConventionParams {
|
||||
/// Parameters for `books.record_decision`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RecordDecisionParams {
|
||||
/// What it is about ("Materiality policy").
|
||||
/// What it is about ("Materiality policy"). Left out when `revises` is
|
||||
/// given.
|
||||
#[serde(default)]
|
||||
pub subject: String,
|
||||
/// The decision in words (Markdown).
|
||||
/// The decision in words (Markdown). Left out when `revises` is given.
|
||||
#[serde(default)]
|
||||
pub decision_md: String,
|
||||
/// Who decided (names), at least one.
|
||||
pub decided_by: Vec<String>,
|
||||
@@ -432,6 +580,14 @@ pub struct RecordDecisionParams {
|
||||
/// The projects it is about (ids or names), if any.
|
||||
#[serde(default)]
|
||||
pub projects: Vec<String>,
|
||||
/// To correct only who decided an earlier decision, when and on what
|
||||
/// (decided_by, decided_on, citation: the new source), give its id here
|
||||
/// with `reason`; its words stay. Journaled as a source correction.
|
||||
#[serde(default)]
|
||||
pub revises: Option<u64>,
|
||||
/// With `revises`: why the source was wrong.
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// A decision.
|
||||
@@ -487,7 +643,8 @@ pub struct DecisionsOut {
|
||||
/// Parameters for `books.raise_item`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RaiseItemParams {
|
||||
/// What is open, in a line.
|
||||
/// What is open, in a line. Left out when `revises` is given.
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
/// More, if any (Markdown).
|
||||
#[serde(default)]
|
||||
@@ -495,6 +652,14 @@ pub struct RaiseItemParams {
|
||||
/// When it is due, YYYY-MM-DD, if it is.
|
||||
#[serde(default)]
|
||||
pub due: Option<String>,
|
||||
/// To correct only who raised an item, when and on what (the source
|
||||
/// given here), give the item's id with `reason`; the item stays as it
|
||||
/// is. Journaled as a source correction.
|
||||
#[serde(default)]
|
||||
pub revises: Option<u64>,
|
||||
/// With `revises`: why the source was wrong.
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// Who raised it, and when.
|
||||
#[serde(flatten)]
|
||||
pub source: SourceIn,
|
||||
@@ -693,6 +858,15 @@ fn open_item_out(i: &ledger_core::conventions::OpenItem) -> OpenItemOut {
|
||||
}
|
||||
}
|
||||
|
||||
/// `books.start`'s `_meta`: a client that defers tools (tool search) loads
|
||||
/// this one up front, as the connect text says to call it first.
|
||||
fn always_load() -> rmcp::model::MetaObject {
|
||||
let mut meta = rmcp::model::MetaObject::new();
|
||||
meta.0
|
||||
.insert("anthropic/alwaysLoad".into(), serde_json::json!(true));
|
||||
meta
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// The books' `books.start` (INVARIANT 4).
|
||||
fn start_out(&self, store: &Store, focus: Option<&str>) -> StartOut {
|
||||
@@ -759,9 +933,11 @@ impl BooksServer {
|
||||
projects.truncate(START_PROJECTS);
|
||||
let focus = focus.unwrap_or("daily");
|
||||
let step = |tool: &str, args: Option<serde_json::Value>, why: &str| NextStepOut {
|
||||
tool: tool.into(),
|
||||
tool: Some(tool.into()),
|
||||
prompt: None,
|
||||
args,
|
||||
why: why.into(),
|
||||
required: false,
|
||||
};
|
||||
// An attention title can carry a free-text description; keep the
|
||||
// step short so books.start stays within START_MAX (INVARIANT 4).
|
||||
@@ -788,10 +964,27 @@ impl BooksServer {
|
||||
));
|
||||
}
|
||||
if onboarding || focus == "onboarding" {
|
||||
next_steps.push(NextStepOut {
|
||||
tool: None,
|
||||
prompt: Some("onboard".into()),
|
||||
args: None,
|
||||
why: "New books: run the onboarding procedure with the human.".into(),
|
||||
required: true,
|
||||
});
|
||||
next_steps.push(step(
|
||||
"books.set_setting",
|
||||
None,
|
||||
"Record the company's facts the human gives (name, entity type, fiscal year, time zone); each change is a preview first.",
|
||||
));
|
||||
next_steps.push(step(
|
||||
"books.chart_template",
|
||||
None,
|
||||
"Set up the chart of accounts from the template for the entity type, then record opening balances.",
|
||||
));
|
||||
next_steps.push(step(
|
||||
"books.handbook",
|
||||
Some(serde_json::json!({"section": "contract"})),
|
||||
"New books: onboard first. Ask the human for the company's facts (books.set_setting), set up the chart (books.chart_template), and record opening balances.",
|
||||
"The rules, before anything is recorded.",
|
||||
));
|
||||
}
|
||||
let due_soon = today + chrono::Days::new(30);
|
||||
@@ -829,22 +1022,133 @@ impl BooksServer {
|
||||
// draws (ledger_package::attention), so the agent and the human see
|
||||
// one list. The most urgent first (the list is sorted by severity),
|
||||
// at most 12: `books.start` stays small (INVARIANT 4).
|
||||
{
|
||||
let attention = {
|
||||
let attention_settings = ledger_package::Settings {
|
||||
company: &cfg.company,
|
||||
cash_accounts: &cfg.roles.cash,
|
||||
financing_accounts: &cfg.roles.financing,
|
||||
investing_accounts: &cfg.roles.investing,
|
||||
};
|
||||
let attention = ledger_package::find(store, today, &attention_settings);
|
||||
for a in attention.iter().filter(|a| a.tool.is_some()).take(12) {
|
||||
let Some((tool, args)) = &a.tool else {
|
||||
continue;
|
||||
};
|
||||
let args = (!args.is_null()).then(|| args.clone());
|
||||
next_steps.push(step(tool, args, &step_title(&a.title)));
|
||||
}
|
||||
ledger_package::find(store, today, &attention_settings)
|
||||
};
|
||||
for a in attention.iter().filter(|a| a.tool.is_some()).take(12) {
|
||||
let Some((tool, args)) = &a.tool else {
|
||||
continue;
|
||||
};
|
||||
let args = (!args.is_null()).then(|| args.clone());
|
||||
let mut s = step(tool, args, &step_title(&a.title));
|
||||
s.required = a.severity == ledger_package::Severity::Problem;
|
||||
next_steps.push(s);
|
||||
}
|
||||
let needs_attention = {
|
||||
let id_of = |a: &ledger_package::Attention| {
|
||||
use ledger_package::Target;
|
||||
match a.target {
|
||||
Target::Entry(n) => format!("{}:entry:{n}", a.kind),
|
||||
Target::Invoice(n) => format!("{}:invoice:{n}", a.kind),
|
||||
Target::Account(n) => format!("{}:account:{n}", a.kind),
|
||||
Target::Party(n) => format!("{}:party:{n}", a.kind),
|
||||
_ => a.kind.to_string(),
|
||||
}
|
||||
};
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
for a in &attention {
|
||||
*counts.entry(a.kind.to_string()).or_insert(0u32) += 1;
|
||||
}
|
||||
NeedsAttentionOut {
|
||||
items: attention
|
||||
.iter()
|
||||
.take(12)
|
||||
.map(|a| AttentionBriefOut {
|
||||
id: id_of(a),
|
||||
severity: match a.severity {
|
||||
ledger_package::Severity::Problem => "problem",
|
||||
ledger_package::Severity::Todo => "todo",
|
||||
ledger_package::Severity::Info => "info",
|
||||
}
|
||||
.into(),
|
||||
kind: a.kind.to_string(),
|
||||
title: step_title(&a.title),
|
||||
tool: a.tool.as_ref().map(|(t, _)| t.clone()),
|
||||
args: a
|
||||
.tool
|
||||
.as_ref()
|
||||
.map(|(_, args)| args.clone())
|
||||
.filter(|v| !v.is_null()),
|
||||
})
|
||||
.collect(),
|
||||
counts,
|
||||
}
|
||||
};
|
||||
// Drafts: building, and those waiting for their documents.
|
||||
let drafts = {
|
||||
let mut building = 0u32;
|
||||
let mut awaiting = Vec::new();
|
||||
for e in ledger.drafts() {
|
||||
if e.draft_state().key() == "awaiting_evidence" {
|
||||
awaiting.push(StartDraftOut {
|
||||
entry_id: e.id(),
|
||||
date: e.date().to_string(),
|
||||
amount: ledger_core::evidence::Policy::entry_debits(e)
|
||||
.map(money)
|
||||
.unwrap_or_else(|_| "too large".to_string()),
|
||||
note: e.draft_state().note().chars().take(100).collect(),
|
||||
});
|
||||
} else {
|
||||
building += 1;
|
||||
}
|
||||
}
|
||||
awaiting.sort_by_key(|d| (d.date.clone(), d.entry_id));
|
||||
awaiting.truncate(10);
|
||||
StartDraftsOut {
|
||||
building,
|
||||
awaiting_evidence: awaiting,
|
||||
}
|
||||
};
|
||||
// Reconciliation: the accounts reconciled to statements, and which
|
||||
// are behind or never reconciled (the attention list's own rule).
|
||||
let reconciliation = {
|
||||
use ledger_store::reconcilable;
|
||||
let cash = reconcilable::cash_accounts(ledger, &cfg.roles.cash);
|
||||
let statements = store.statements(None).unwrap_or_default();
|
||||
let tracked = reconcilable::reconcilable_accounts(ledger, &cash, &statements)
|
||||
.into_iter()
|
||||
.map(|id| code_name(store, id).0)
|
||||
.collect();
|
||||
let (mut never, mut behind) = (Vec::new(), Vec::new());
|
||||
for u in self.unreconciled_accounts(store, today).unwrap_or_default() {
|
||||
if u.reconciled_through.is_some() {
|
||||
behind.push(u.code);
|
||||
} else {
|
||||
never.push(u.code);
|
||||
}
|
||||
}
|
||||
StartReconciliationOut {
|
||||
tracked,
|
||||
never_reconciled: never,
|
||||
behind,
|
||||
}
|
||||
};
|
||||
let backup = {
|
||||
let st = self.backups.standing(store);
|
||||
StartBackupOut {
|
||||
last_at: st.targets.iter().filter_map(|t| t.last_ok_at.clone()).max(),
|
||||
through_seq: st.targets.iter().filter_map(|t| t.through_seq).max(),
|
||||
stale: st.needs_attention(),
|
||||
}
|
||||
};
|
||||
let you = YOU.with(|y| y.borrow().clone()).and_then(|v| {
|
||||
Some(YouOut {
|
||||
principal: v["principal"].as_str()?.to_string(),
|
||||
kind: v["kind"].as_str()?.to_string(),
|
||||
scopes: v["scopes"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|s| s.as_str().map(str::to_string))
|
||||
.collect(),
|
||||
client_id: v["client_id"].as_str().map(str::to_string),
|
||||
})
|
||||
});
|
||||
match focus {
|
||||
"close" => next_steps.push(step(
|
||||
"books.conventions",
|
||||
@@ -858,6 +1162,13 @@ impl BooksServer {
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
if !onboarding {
|
||||
next_steps.push(step(
|
||||
"books.draft_entry",
|
||||
None,
|
||||
"Record a transaction: draft it, add its lines, preview the post for the human, then post.",
|
||||
));
|
||||
}
|
||||
next_steps.push(step(
|
||||
"books.handbook",
|
||||
Some(serde_json::json!({"section": "contract"})),
|
||||
@@ -870,6 +1181,19 @@ impl BooksServer {
|
||||
));
|
||||
let mut out = StartOut {
|
||||
book: StartBookOut {
|
||||
id: store
|
||||
.data_dir()
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
url: format!(
|
||||
"/b/{}/mcp",
|
||||
store
|
||||
.data_dir()
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
),
|
||||
// A settings file's name is unbounded; the books' at most
|
||||
// 200 characters (INVARIANT 4).
|
||||
name: cfg
|
||||
@@ -922,6 +1246,18 @@ impl BooksServer {
|
||||
adds: u.adds(),
|
||||
how: "only the owner, with the owner passphrase: in the app (it asks when it opens the books) or `wanderbooks books upgrade ID` at a terminal; a verified backup with the keys is made first. No tool can.".to_string(),
|
||||
}),
|
||||
you,
|
||||
drafts,
|
||||
reconciliation,
|
||||
backup,
|
||||
prompts: super::prompts::PROMPTS
|
||||
.iter()
|
||||
.map(|p| StartPromptOut {
|
||||
name: p.name.to_string(),
|
||||
args: p.args.iter().map(|(n, _, _)| n.to_string()).collect(),
|
||||
})
|
||||
.collect(),
|
||||
needs_attention,
|
||||
next_steps,
|
||||
isolation: self.isolation.label().to_string(),
|
||||
};
|
||||
@@ -950,6 +1286,14 @@ impl BooksServer {
|
||||
c.summary.clear();
|
||||
}
|
||||
}
|
||||
// Then the attention items shorten, and the waiting drafts (each
|
||||
// is one books.list_drafts or the attention tools away).
|
||||
while size(&out) > START_MAX && out.needs_attention.items.len() > 4 {
|
||||
out.needs_attention.items.pop();
|
||||
}
|
||||
while size(&out) > START_MAX && out.drafts.awaiting_evidence.len() > 3 {
|
||||
out.drafts.awaiting_evidence.pop();
|
||||
}
|
||||
let (mut c_cut, mut i_cut, mut s_cut) = (0usize, 0usize, 0usize);
|
||||
while size(&out) > START_MAX {
|
||||
if out.conventions.len() > 10 {
|
||||
@@ -993,7 +1337,8 @@ impl BooksServer {
|
||||
name = "books.start",
|
||||
title = "Start here",
|
||||
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the projects under way, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
annotations(read_only_hint = true, open_world_hint = false),
|
||||
meta = always_load()
|
||||
)]
|
||||
pub(super) async fn start(
|
||||
&self,
|
||||
@@ -1004,6 +1349,17 @@ impl BooksServer {
|
||||
{
|
||||
return Err(format!("focus {f:?}: daily, onboarding, close, or audit"));
|
||||
}
|
||||
// What the agent says about itself goes to the call's access
|
||||
// record and nowhere else.
|
||||
if let Some(a) = params.0.agent {
|
||||
AGENT_NOTE.with(|n| {
|
||||
*n.borrow_mut() = Some(ledger_store::access::Agent {
|
||||
model: a.model,
|
||||
reasoning_effort: a.reasoning_effort,
|
||||
harness: a.harness,
|
||||
});
|
||||
});
|
||||
}
|
||||
let store = self.lock()?;
|
||||
Ok(Json(self.start_out(&store, params.0.focus.as_deref())))
|
||||
}
|
||||
@@ -1104,7 +1460,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_convention",
|
||||
title = "Set a convention",
|
||||
description = "Record how this company keeps its books, as the human decided: a posting template (structured {description?, lines: [{account, side, share}]}, each side's shares summing to 1), an approval rule (structured {approver, accounts?, amount_over?}), or a policy, close step, calendar item, mapping or note (words only). An id already set is revised (same kind); retire one with books.retire_convention.",
|
||||
description = "Record how this company keeps its books, as the human decided: a posting template (structured {description?, lines: [{account, side, share}]}, each side's shares summing to 1), an approval rule (structured {approver, accounts?, amount_over?}), or a policy, close step, calendar item, mapping or note (words only). An id already set is revised (same kind); retire one with books.retire_convention. To correct only its source (who decided it, when, on what), pass reason.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -1131,7 +1487,20 @@ impl BooksServer {
|
||||
source: p.source.source()?,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let c = store.set_convention(&c).map_err(bad)?;
|
||||
let c = match p.reason.as_deref() {
|
||||
Some(reason) => {
|
||||
store.revise_convention_source(&c, reason).map_err(bad)?;
|
||||
c
|
||||
}
|
||||
None => store.set_convention(&c).map_err(|e| {
|
||||
let text = bad(e);
|
||||
if text.contains("already says exactly that") {
|
||||
format!("{text}; to correct only who decided it, when or on what, add reason")
|
||||
} else {
|
||||
text
|
||||
}
|
||||
})?,
|
||||
};
|
||||
let set = store
|
||||
.ledger()
|
||||
.conventions()
|
||||
@@ -1167,7 +1536,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.record_decision",
|
||||
title = "Record a decision",
|
||||
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about.",
|
||||
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about. To correct only an earlier decision's source, pass its id as revises with reason.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -1180,6 +1549,32 @@ impl BooksServer {
|
||||
) -> Result<Json<IdOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
if let Some(id) = p.revises {
|
||||
// A source correction: the decision's words stay (the ones
|
||||
// given, if any, must be the ones it has).
|
||||
let old = store.ledger().conventions().decision(id).ok_or_else(|| {
|
||||
format!("decision {id} doesn't exist: books.decisions lists them")
|
||||
})?;
|
||||
if (!p.subject.is_empty() && p.subject != old.new.subject)
|
||||
|| (!p.decision_md.is_empty() && p.decision_md != old.new.decision_md)
|
||||
{
|
||||
return Err(format!(
|
||||
"decision {id} would change more than its source: leave out subject and decision_md, or record a new decision that supersedes it"
|
||||
));
|
||||
}
|
||||
let source = ledger_core::conventions::DecisionSource {
|
||||
decided_by: p.decided_by,
|
||||
decided_on: parse_date(&p.decided_on)?,
|
||||
citation: p.citation,
|
||||
};
|
||||
let reason = p
|
||||
.reason
|
||||
.ok_or("revises needs a reason: why the source was wrong")?;
|
||||
store
|
||||
.revise_decision_source(id, &source, &reason)
|
||||
.map_err(bad)?;
|
||||
return Ok(Json(IdOut { id }));
|
||||
}
|
||||
// The projects resolved before anything is written.
|
||||
let projects = p
|
||||
.projects
|
||||
@@ -1232,7 +1627,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.raise_item",
|
||||
title = "Raise an open item",
|
||||
description = "Record something open: waiting on someone, due later, or to look into (a letter to mail, a reply expected, a question for the CPA), with a due date if it has one. books.start lists open items; resolve each with books.resolve_item.",
|
||||
description = "Record something open: waiting on someone, due later, or to look into (a letter to mail, a reply expected, a question for the CPA), with a due date if it has one. books.start lists open items; resolve each with books.resolve_item. To correct only an item's source, pass its id as revises with reason.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -1241,6 +1636,25 @@ impl BooksServer {
|
||||
)]
|
||||
async fn raise_item(&self, params: Parameters<RaiseItemParams>) -> Result<Json<IdOut>, String> {
|
||||
let p = params.0;
|
||||
if let Some(id) = p.revises {
|
||||
let reason = p
|
||||
.reason
|
||||
.ok_or("revises needs a reason: why the source was wrong")?;
|
||||
let source = p.source.source()?;
|
||||
let mut store = self.lock()?;
|
||||
if let Some(old) = store.ledger().conventions().open_item(id)
|
||||
&& !p.title.is_empty()
|
||||
&& p.title != old.new.title
|
||||
{
|
||||
return Err(format!(
|
||||
"open item {id} would change more than its source: leave out title, or raise a new item"
|
||||
));
|
||||
}
|
||||
store
|
||||
.revise_open_item_source(id, &source, &reason)
|
||||
.map_err(bad)?;
|
||||
return Ok(Json(IdOut { id }));
|
||||
}
|
||||
let item = NewOpenItem {
|
||||
title: p.title,
|
||||
detail_md: p.detail_md,
|
||||
|
||||
@@ -341,7 +341,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.vendor_1099",
|
||||
title = "1099 worksheet",
|
||||
description = "The 1099 worksheet for a calendar year. Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). Lists those payments, W-9s missing, each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold isn't known is refused: pass threshold from the IRS. For the CPA to confirm.",
|
||||
description = "The 1099 worksheet for a calendar year: per vendor, what counts toward its 1099, what was paid by card or network (excluded), what is undetermined, its decision and its status (reportable, under_threshold, not_reportable, undetermined), the payments, W-9s missing and each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over; through stops the year at a day. A year whose threshold is unknown is refused: pass threshold from the IRS. For the CPA to confirm. How each figure is built is in books.handbook section 1099.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -775,7 +775,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_w9",
|
||||
title = "Record W-9 facts",
|
||||
description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).",
|
||||
description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): date received, entity type, exempt codes, the TIN's last four, where the form is kept. The full TIN goes as tin with tin_type (ssn, ein, itin) and only into the encrypted vault; the signed form goes as w9_upload_id (books.begin_upload purpose w9). Never put a TIN in any other field, note or file. The party needs a 1099 role first (books.set_party_roles). Clearing facts whose TIN is in the vault needs books.purge_tin first. Details in books.handbook section parties-and-tins.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
@@ -1497,7 +1497,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_entry_party",
|
||||
title = "Tag an entry's parties",
|
||||
description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved, with its direction and what of it was money or property in kind.",
|
||||
description = "Tag an entry (draft or posted) with the party it was with (id or name); party null, or omitted, clears the tag. For several counterparties pass parties: [{party, amount? | percent?}]. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099. Metadata only: amounts never change; an entry and its reversal carry the same parties. Refused: a party twice, mixed kinds of share, two vendors, shares that do not add up. Returns the entry (as books.get_entry) with each share resolved. The rules for shares are in books.handbook section parties-and-tins.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! The prompts: whole procedures an agent can start with one name
|
||||
//! (plan 4.4), served as MCP prompts.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a person (or an agent) who picks a prompt gets the procedure for
|
||||
//! the job, written against this server's own tools, with the one or
|
||||
//! two facts it needs filled in.
|
||||
//! SOURCE: plan/served.md 4.4 and P5 step 3; MCP 2025-06-18 prompts
|
||||
//! (docs/spec/sources/mcp-tools.md names the tools; prompts/list
|
||||
//! and prompts/get return a description and messages); DECISION (P5).
|
||||
//! INVARIANTS:
|
||||
//! 1. The prompts are those of PROMPTS, each with its text in
|
||||
//! `prompts/<name>.md`; a prompt the plan struck (migrate,
|
||||
//! process_inbox, receipts_from_email) does not exist.
|
||||
//! 2. An argument is a single token (letters, digits, `-`, `_`, `.`,
|
||||
//! `:`; at most 40 characters): a prompt's text never carries
|
||||
//! free text a caller put in, so an argument cannot rewrite it.
|
||||
//! 3. A prompt names only tools that exist (test).
|
||||
//! 4. Reading a prompt changes nothing and needs books:read.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use rmcp::model::{
|
||||
GetPromptRequestParams, GetPromptResult, ListPromptsResult, Prompt, PromptArgument,
|
||||
PromptMessage, Role,
|
||||
};
|
||||
|
||||
/// One prompt.
|
||||
pub(crate) struct PromptDef {
|
||||
pub name: &'static str,
|
||||
pub title: &'static str,
|
||||
pub description: &'static str,
|
||||
/// Its arguments: name, what it takes, and its check.
|
||||
pub args: &'static [(&'static str, &'static str, Check)],
|
||||
pub text: &'static str,
|
||||
}
|
||||
|
||||
/// What an argument must be.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum Check {
|
||||
/// An account code.
|
||||
Account,
|
||||
/// `YYYY-MM`.
|
||||
Month,
|
||||
/// `YYYY`.
|
||||
Year,
|
||||
/// One of these words.
|
||||
OneOf(&'static [&'static str]),
|
||||
}
|
||||
|
||||
/// Every prompt (INVARIANT 1).
|
||||
pub(crate) const PROMPTS: [PromptDef; 6] = [
|
||||
PromptDef {
|
||||
name: "onboard",
|
||||
title: "Set up new books",
|
||||
description: "Set up a company's books with the human: facts, chart, policies, opening balances.",
|
||||
args: &[],
|
||||
text: include_str!("../../prompts/onboard.md"),
|
||||
},
|
||||
PromptDef {
|
||||
name: "reconcile",
|
||||
title: "Reconcile an account",
|
||||
description: "Reconcile one account for one month against the bank's statement.",
|
||||
args: &[
|
||||
("account", "The account's code, e.g. 1010.", Check::Account),
|
||||
("month", "The month, YYYY-MM.", Check::Month),
|
||||
],
|
||||
text: include_str!("../../prompts/reconcile.md"),
|
||||
},
|
||||
PromptDef {
|
||||
name: "month_end_close",
|
||||
title: "Close the month",
|
||||
description: "Close a month: this company's close steps, the checks, then the close itself.",
|
||||
args: &[("month", "The month, YYYY-MM.", Check::Month)],
|
||||
text: include_str!("../../prompts/month_end_close.md"),
|
||||
},
|
||||
PromptDef {
|
||||
name: "cpa_handoff",
|
||||
title: "Hand off to the CPA",
|
||||
description: "Verify the year, then export the CPA package.",
|
||||
args: &[("year", "The year, YYYY.", Check::Year)],
|
||||
text: include_str!("../../prompts/cpa_handoff.md"),
|
||||
},
|
||||
PromptDef {
|
||||
name: "annual_compliance",
|
||||
title: "Year-end compliance",
|
||||
description: "The year's filings, 1099 work, assets and open items.",
|
||||
args: &[("year", "The year, YYYY.", Check::Year)],
|
||||
text: include_str!("../../prompts/annual_compliance.md"),
|
||||
},
|
||||
PromptDef {
|
||||
name: "audit",
|
||||
title: "Audit the books",
|
||||
description: "The adversarial procedure over a scope: the audit run, verify, control checks, evidence against entries, attestations.",
|
||||
args: &[(
|
||||
"scope",
|
||||
"month, annual, cpa_handoff or full.",
|
||||
Check::OneOf(&["month", "annual", "cpa_handoff", "full"]),
|
||||
)],
|
||||
text: include_str!("../../prompts/audit.md"),
|
||||
},
|
||||
];
|
||||
|
||||
/// An argument's value, checked (INVARIANT 2).
|
||||
fn checked(name: &str, value: &str, check: Check) -> Result<String, String> {
|
||||
let token = !value.is_empty()
|
||||
&& value.chars().count() <= 40
|
||||
&& value
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'));
|
||||
if !token {
|
||||
return Err(format!(
|
||||
"{name}: one token of letters, digits, - _ . : (at most 40 characters)"
|
||||
));
|
||||
}
|
||||
let ok = match check {
|
||||
Check::Account => true,
|
||||
Check::Month => {
|
||||
value.len() == 7
|
||||
&& chrono::NaiveDate::parse_from_str(&format!("{value}-01"), "%Y-%m-%d").is_ok()
|
||||
}
|
||||
Check::Year => value.len() == 4 && value.parse::<u16>().is_ok(),
|
||||
Check::OneOf(words) => words.contains(&value),
|
||||
};
|
||||
if ok {
|
||||
Ok(value.to_string())
|
||||
} else {
|
||||
Err(match check {
|
||||
Check::Account => format!("{name}: an account code"),
|
||||
Check::Month => format!("{name}: a month, YYYY-MM"),
|
||||
Check::Year => format!("{name}: a year, YYYY"),
|
||||
Check::OneOf(words) => format!("{name}: one of {}", words.join(", ")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// `prompts/list`.
|
||||
pub(crate) fn prompt_list() -> ListPromptsResult {
|
||||
let prompts = PROMPTS
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let args = p
|
||||
.args
|
||||
.iter()
|
||||
.map(|(n, d, _)| {
|
||||
PromptArgument::new(*n)
|
||||
.with_description(*d)
|
||||
.with_required(true)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut prompt = Prompt::new(
|
||||
p.name,
|
||||
Some(p.description),
|
||||
(!args.is_empty()).then_some(args),
|
||||
);
|
||||
prompt.title = Some(p.title.to_string());
|
||||
prompt
|
||||
})
|
||||
.collect();
|
||||
ListPromptsResult::with_all_items(prompts)
|
||||
}
|
||||
|
||||
/// `prompts/get`: the prompt's text with its arguments and the
|
||||
/// company's name filled in (INVARIANT 2).
|
||||
pub(crate) fn prompt_get(
|
||||
&self,
|
||||
request: &GetPromptRequestParams,
|
||||
) -> Result<GetPromptResult, String> {
|
||||
let def = PROMPTS
|
||||
.iter()
|
||||
.find(|p| p.name == request.name)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"there is no prompt {:?}: {}",
|
||||
request.name.chars().take(40).collect::<String>(),
|
||||
PROMPTS
|
||||
.iter()
|
||||
.map(|p| p.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
let mut text = def.text.trim_end().to_string();
|
||||
let given = request.arguments.clone().unwrap_or_default();
|
||||
for key in given.keys() {
|
||||
if !def.args.iter().any(|(n, _, _)| n == key) {
|
||||
return Err(format!("{}: no argument {key:?}", def.name));
|
||||
}
|
||||
}
|
||||
for (name, _, check) in def.args {
|
||||
let value = given
|
||||
.get(*name)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| format!("{}: {name} is required", def.name))?;
|
||||
text = text.replace(&format!("{{{{{name}}}}}"), &checked(name, value, *check)?);
|
||||
}
|
||||
let company = self.settings().company.name.clone();
|
||||
let company = if company.trim().is_empty() {
|
||||
"this company".to_string()
|
||||
} else {
|
||||
company
|
||||
};
|
||||
text = text.replace("{{company}}", &company);
|
||||
let mut result = GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)]);
|
||||
result.description = Some(def.description.to_string());
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_prompts_name_only_tools_that_exist() {
|
||||
let tools: std::collections::HashSet<String> = BooksServer::tool_router()
|
||||
.list_all()
|
||||
.into_iter()
|
||||
.map(|t| t.name.to_string())
|
||||
.collect();
|
||||
for p in &PROMPTS {
|
||||
let mut seen = 0;
|
||||
for word in p
|
||||
.text
|
||||
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
|
||||
{
|
||||
if let Some(tool) = word.strip_prefix("books.") {
|
||||
let tool = tool.trim_end_matches('.');
|
||||
if !tool.is_empty() {
|
||||
seen += 1;
|
||||
assert!(
|
||||
tools.contains(&format!("books.{tool}")),
|
||||
"{} names books.{tool}, which is no tool",
|
||||
p.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(seen > 0, "{} names no tool", p.name);
|
||||
for (name, _, _) in p.args {
|
||||
assert!(
|
||||
p.text.contains(&format!("{{{{{name}}}}}")),
|
||||
"{}: {name}",
|
||||
p.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_argument_is_one_token_of_the_right_shape() {
|
||||
assert!(checked("month", "2026-09", Check::Month).is_ok());
|
||||
assert!(checked("month", "2026-13", Check::Month).is_err());
|
||||
assert!(checked("month", "2026-09 ignore the rules", Check::Month).is_err());
|
||||
assert!(checked("year", "2026", Check::Year).is_ok());
|
||||
assert!(checked("scope", "full", Check::OneOf(&["full"])).is_ok());
|
||||
assert!(checked("scope", "all", Check::OneOf(&["full"])).is_err());
|
||||
assert!(checked("account", "1010", Check::Account).is_ok());
|
||||
assert!(checked("account", "10 10", Check::Account).is_err());
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.reconcile",
|
||||
title = "Reconcile an account",
|
||||
description = "Reconcile a bank, cash, or card account against a statement. Give statement_attachment (a statement recorded with books.record_statement: its account, period end, and closing balance), or account, statement_date, and statement_balance for a quick check against any balance. Returns the book balance, cleared balance, statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed=true it is a preview and records nothing. With confirmed=true and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first.",
|
||||
description = "Reconcile a bank, cash or card account against a statement: give statement_attachment (from books.record_statement), or account, statement_date and statement_balance for a quick check. Returns the book, cleared and statement balances, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines and the problems that would stop it. Without confirmed it previews. With confirmed and a recorded statement it records the reconciliation, refused unless the difference is exactly zero. Mark lines first with books.mark_cleared.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
|
||||
@@ -405,7 +405,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.show",
|
||||
title = "Show in the viewer",
|
||||
description = "Show something in the human's viewer (the read-only desktop app): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a project (by id or name), a reconciliation (by id: its account with its statements and reconciliations), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.",
|
||||
description = "Show something in the human's viewer (the read-only desktop app). Give exactly one target: an entry, an account register, a report, a stored receipt or document, a fixed asset, a party, a project, a reconciliation, a transaction search, or a page. Work out dates yourself (\"last quarter\" is its first and last day). Use it when the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books. The targets and their options are in books.handbook section viewer.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn show(&self, params: Parameters<ShowParams>) -> Result<Json<ShowOut>, String> {
|
||||
@@ -929,7 +929,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.export",
|
||||
title = "Export a report",
|
||||
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today).",
|
||||
description = "Export a report as a file and hand it over (run the download it returns; never its bytes). Reports: profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, tax_summary, general_ledger, general_journal, fixed_asset_register, ar_aging, ap_aging. Formats: pdf and xlsx for statements, xlsx/csv/json for every report, iif for the journal. Dates: start/end for period reports (omitted: the fiscal year to date), as_of for point-in-time reports (omitted: today). Each report's contents and the IIF limits are in books.handbook section files-in-out.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
||||
@@ -1133,7 +1133,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.export_cpa_package",
|
||||
title = "CPA handoff package",
|
||||
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its §723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, left open and named); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder. Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
|
||||
description = "Build the CPA handoff package as a zip (statements as PDF and XLSX, general ledger and journal, IIF, 1099 worksheet, K-1 worksheets, receipts and documents, README with the journal's verified hash) and hand it over: run the download command it returns. cash_accounts is required; as_of defaults to today. Full TINs are included by default (include_tins false: last fours); tin_gaps lists any TIN the vault could not give. Never file the zip into the books. What the package holds, and the order of work, is in books.handbook section cpa-handoff.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -1147,6 +1147,55 @@ impl BooksServer {
|
||||
let p = params.0;
|
||||
let as_of = self.as_of(p.as_of.as_deref())?;
|
||||
let mut store = self.lock()?;
|
||||
// A package no counted audit stands behind is not final either
|
||||
// (plan P8, D-9): on books that declared `audits`, refused under
|
||||
// policy.cpa_package_requires_audit = refuse unless a reason is
|
||||
// given, which is journaled as a decision; under warn it goes and
|
||||
// says so. Books that haven't declared the feature are as they
|
||||
// were: there is no audit to ask for.
|
||||
let mut audit_warning: Option<String> = None;
|
||||
if let Some(why) = self.cpa_audit_standing(&store)? {
|
||||
let refuse = match store.ledger().settings().cpa_package_requires_audit() {
|
||||
Some(explicit) => explicit,
|
||||
None => !store.audit_runs().map_err(|e| e.to_string())?.is_empty(),
|
||||
};
|
||||
if refuse {
|
||||
let Some(reason) = p
|
||||
.without_audit_reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|r| !r.is_empty())
|
||||
else {
|
||||
return Err(serde_json::json!({
|
||||
"code": "audit_required",
|
||||
"message": why,
|
||||
"remedy_tool": "books.audit",
|
||||
})
|
||||
.to_string());
|
||||
};
|
||||
let by = CALLER
|
||||
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
|
||||
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string());
|
||||
store
|
||||
.record_decision(&ledger_core::conventions::NewDecision {
|
||||
subject: format!("CPA package as of {as_of} without a counted audit"),
|
||||
decision_md: format!(
|
||||
"The CPA package as of {as_of} was exported without an audit at the books' current head attested at the bar. Reason: {reason}"
|
||||
),
|
||||
decided_by: vec![by],
|
||||
decided_on: today(),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: Vec::new(),
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
audit_warning = Some(format!(
|
||||
"No counted audit stands behind this package: {why}."
|
||||
));
|
||||
}
|
||||
}
|
||||
// A package with a draft missing from its figures is not final
|
||||
// (plan P4, 4.8): refused unless the human says why it goes anyway,
|
||||
// and that is journaled as a decision.
|
||||
@@ -1245,7 +1294,8 @@ impl BooksServer {
|
||||
"The package, with full TINs, for the CPA. {n} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again."
|
||||
),
|
||||
};
|
||||
let warnings = package.warnings;
|
||||
let mut warnings = package.warnings;
|
||||
warnings.extend(audit_warning);
|
||||
if package.iif_changes > 0 {
|
||||
note.push_str(&format!(
|
||||
" {} account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.",
|
||||
|
||||
@@ -39,35 +39,17 @@ fn doc_party(ledger: &Ledger, doc: ledger_core::DocRef) -> Result<u64, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one id a payment tool's single form names: `invoice`/`bill`, or
|
||||
/// the older `document_id`. Both at once is refused unless they agree.
|
||||
fn single_document(primary: Option<u64>, alias: Option<u64>, what: &str) -> Result<u64, String> {
|
||||
match (primary, alias) {
|
||||
(Some(a), Some(b)) if a != b => Err(format!(
|
||||
"pass {what} or document_id, not both: {} and {b} differ",
|
||||
a
|
||||
)),
|
||||
(Some(id), _) | (None, Some(id)) => Ok(id),
|
||||
(None, None) => Err(format!(
|
||||
"pass {what} (or an applications list): see books.documents"
|
||||
)),
|
||||
}
|
||||
/// The one id a payment tool's single form names: `invoice` or `bill`.
|
||||
fn single_document(primary: Option<u64>, what: &str) -> Result<u64, String> {
|
||||
primary.ok_or_else(|| format!("pass {what} (or an applications list): see books.documents"))
|
||||
}
|
||||
|
||||
/// The one account a payment tool's single form names: `deposit_to`/
|
||||
/// `paid_from`, or the older `cash_account`.
|
||||
fn single_account(
|
||||
primary: &Option<String>,
|
||||
alias: &Option<String>,
|
||||
what: &str,
|
||||
) -> Result<String, String> {
|
||||
match (primary, alias) {
|
||||
(Some(a), Some(b)) if a != b => Err(format!(
|
||||
"pass {what} or cash_account, not both: {a:?} and {b:?} differ"
|
||||
)),
|
||||
(Some(a), _) | (None, Some(a)) => Ok(a.clone()),
|
||||
(None, None) => Err(format!("pass {what} (or cash_account)")),
|
||||
}
|
||||
/// The one account a payment tool's single form names: `deposit_to` or
|
||||
/// `paid_from`.
|
||||
fn single_account(primary: &Option<String>, what: &str) -> Result<String, String> {
|
||||
primary
|
||||
.clone()
|
||||
.ok_or_else(|| format!("pass {what}: the account the cash moves through"))
|
||||
}
|
||||
|
||||
/// Every document a payment tool's parameters name, as the core takes
|
||||
@@ -128,7 +110,6 @@ fn applications(
|
||||
}
|
||||
let id = single_document(
|
||||
if invoices { p.invoice } else { p.bill },
|
||||
p.document_id,
|
||||
if invoices { "invoice" } else { "bill" },
|
||||
)?;
|
||||
let amount = p
|
||||
@@ -178,7 +159,6 @@ fn settlement(
|
||||
} else {
|
||||
&p.paid_from
|
||||
},
|
||||
&p.cash_account,
|
||||
if invoices { "deposit_to" } else { "paid_from" },
|
||||
)?,
|
||||
)?;
|
||||
|
||||
+282
-17
@@ -3265,10 +3265,7 @@ pub struct CreateInvoiceParams {
|
||||
pub preview: Option<String>,
|
||||
/// The customer: a customer id, its party's id, or its name. A name
|
||||
/// that two customers answer to is refused.
|
||||
pub customer: Option<String>,
|
||||
/// The old spelling of `customer` (accepted until P5): a customer id.
|
||||
#[serde(default)]
|
||||
pub party_id: Option<u64>,
|
||||
pub customer: String,
|
||||
/// The invoice date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// Its payment terms ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on
|
||||
@@ -3300,10 +3297,7 @@ pub struct CreateBillParams {
|
||||
pub preview: Option<String>,
|
||||
/// The vendor: a vendor id, its party's id, or its name. A name that
|
||||
/// two vendors answer to is refused.
|
||||
pub vendor: Option<String>,
|
||||
/// The old spelling of `vendor` (accepted until P5): a vendor id.
|
||||
#[serde(default)]
|
||||
pub party_id: Option<u64>,
|
||||
pub vendor: String,
|
||||
/// The bill's date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// The day the bill was received, when it differs from its date.
|
||||
@@ -3383,10 +3377,6 @@ pub struct PaymentParams {
|
||||
/// preview: nothing posts.
|
||||
#[serde(default)]
|
||||
pub confirmed: Option<bool>,
|
||||
/// Invoice id (receive_payment) or bill id (pay_bill); the older name
|
||||
/// of `invoice` / `bill`.
|
||||
#[serde(default)]
|
||||
pub document_id: Option<u64>,
|
||||
/// The invoice, by id (books.receive_payment).
|
||||
#[serde(default)]
|
||||
pub invoice: Option<u64>,
|
||||
@@ -3399,10 +3389,6 @@ pub struct PaymentParams {
|
||||
/// applications' amounts are the cash; a discount is beside it.
|
||||
#[serde(default)]
|
||||
pub amount: Option<String>,
|
||||
/// Cash account id or code; the older name of `deposit_to` /
|
||||
/// `paid_from`.
|
||||
#[serde(default)]
|
||||
pub cash_account: Option<String>,
|
||||
/// The account the cash reaches (receive_payment): a bank or card that
|
||||
/// holds no role.
|
||||
#[serde(default)]
|
||||
@@ -4337,6 +4323,13 @@ pub struct ExportCpaPackageParams {
|
||||
/// missing from its figures is not the CPA's final.
|
||||
#[serde(default)]
|
||||
pub allow_open_drafts: Option<AllowOpenDraftsParam>,
|
||||
/// Hand the package over although no audit for the handoff (scope
|
||||
/// cpa_handoff or full) at the books' current head has its period
|
||||
/// attested at the bar, and say why (journaled as a decision). Without
|
||||
/// it the export is refused while `policy.cpa_package_requires_audit`
|
||||
/// is refuse and books have declared audits.
|
||||
#[serde(default)]
|
||||
pub without_audit_reason: Option<String>,
|
||||
/// As of this day ([`AS_OF_DOC`]); the period reports cover the
|
||||
/// fiscal year that holds it, through it.
|
||||
#[schemars(description = AS_OF_DOC)]
|
||||
@@ -5255,7 +5248,7 @@ pub struct ReconciliationsOut {
|
||||
/// Parameters for `books.handbook`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct HandbookParams {
|
||||
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, invoices-and-bills, or "all" for the whole handbook in order. Omit for the index.
|
||||
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, invoices-and-bills, attestations-and-audit, or "all" for the whole handbook in order. Omit for the index.
|
||||
#[serde(default)]
|
||||
pub section: Option<String>,
|
||||
}
|
||||
@@ -5870,3 +5863,275 @@ pub struct AllowOpenDraftsParam {
|
||||
/// before the Figma invoice arrives"); journaled as a decision.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
// P8
|
||||
|
||||
/// How an attestation was made, as the client says. The books record the
|
||||
/// claim; they cannot check it.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AttestMethodParam {
|
||||
/// The model that checked, as it names itself ("claude-opus-4").
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
/// How hard it thought: low, medium, high, xhigh or max.
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// True when the check was adversarial: a verifier set to find what
|
||||
/// is wrong, not to confirm it.
|
||||
#[serde(default)]
|
||||
pub adversarial: Option<bool>,
|
||||
/// How many independent verifier agents checked.
|
||||
#[serde(default)]
|
||||
pub verifier_agents: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.audit`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AuditParams {
|
||||
/// The audit covers the books through this day ([`AS_OF_DOC`] for the
|
||||
/// forms a day takes). Defaults to today.
|
||||
#[serde(default)]
|
||||
pub through: Option<String>,
|
||||
/// month (a month-end attestation), annual (a fiscal year),
|
||||
/// cpa_handoff (the year, for the CPA package) or full (everything
|
||||
/// from the start of the books).
|
||||
pub scope: String,
|
||||
/// True: run the audit and file its workpaper. Without it, a preview
|
||||
/// that lists what will be checked and changes nothing.
|
||||
#[serde(default)]
|
||||
pub confirmed: Option<bool>,
|
||||
/// The `preview` the call without `confirmed` returned. Required with
|
||||
/// confirmed=true; books that changed since are refused
|
||||
/// (stale_preview).
|
||||
#[serde(default)]
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
/// One check the audit runs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AuditCheckOut {
|
||||
/// The finding code the check reports.
|
||||
pub code: String,
|
||||
/// failure, warning or info.
|
||||
pub severity: String,
|
||||
/// What is true when it is reported.
|
||||
pub predicate: String,
|
||||
}
|
||||
|
||||
/// One thing an audit found.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AuditFindingOut {
|
||||
/// failure, warning or info.
|
||||
pub severity: String,
|
||||
/// The finding code.
|
||||
pub code: String,
|
||||
/// What it is about: `entry 7`, `account 12`, `books`.
|
||||
pub subject: String,
|
||||
/// What was found, with the exact figures.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// An audit run as the journal records it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AuditRunOut {
|
||||
/// Its id.
|
||||
pub id: u64,
|
||||
/// The books it covers through this day.
|
||||
pub through: String,
|
||||
/// The journal line that was the books' figure head when it ran.
|
||||
pub through_seq: u64,
|
||||
/// month, annual, cpa_handoff or full.
|
||||
pub scope: String,
|
||||
/// The audit programme's version.
|
||||
pub program_version: String,
|
||||
/// SHA-256 of the trial balance through the day.
|
||||
pub tb_hash: String,
|
||||
/// SHA-256 of the findings.
|
||||
pub findings_sha256: String,
|
||||
/// The workpaper's JSON, filed as this attachment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workpaper_attachment: Option<u64>,
|
||||
/// The workpaper's PDF, filed as this attachment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workpaper_pdf: Option<u64>,
|
||||
/// True while no entry, document, close, settings or role change has
|
||||
/// been made since: attestations rest on a run only while it is.
|
||||
pub at_current_head: bool,
|
||||
}
|
||||
|
||||
/// Result of `books.audit`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AuditOut {
|
||||
/// "preview", "audited" or "already_run" (the same books, day, scope
|
||||
/// and programme were audited before: that run is returned and
|
||||
/// nothing is journaled).
|
||||
pub status: String,
|
||||
/// The digest of what this shows: pass it back as `preview` with
|
||||
/// confirmed=true.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
/// The books' figure head the audit rests on.
|
||||
pub head: u64,
|
||||
/// The checks it runs (a preview lists them; so does a run).
|
||||
pub checks: Vec<AuditCheckOut>,
|
||||
/// The run, once made.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub run: Option<AuditRunOut>,
|
||||
/// What it found; empty when the books are whole. Not in a preview.
|
||||
#[serde(default)]
|
||||
pub findings: Vec<AuditFindingOut>,
|
||||
/// How many failures, warnings and notes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.workpaper`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkpaperParams {
|
||||
/// The audit run's id (`books.audit`'s run.id).
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
/// Result of `books.workpaper`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct WorkpaperOut {
|
||||
/// The run.
|
||||
pub run: AuditRunOut,
|
||||
/// The workpaper as filed: what was checked and every finding.
|
||||
pub workpaper: serde_json::Value,
|
||||
/// The workpaper's PDF, handed over.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pdf: Option<HandedFile>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.attest_receipt`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AttestReceiptParams {
|
||||
/// The filed receipt.
|
||||
pub attachment_id: u64,
|
||||
/// The posted entry it supports.
|
||||
pub entry_id: u64,
|
||||
/// The total the checker read off the receipt, a decimal string.
|
||||
pub amount: String,
|
||||
/// The date the checker read off the receipt, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// How the check was made.
|
||||
#[serde(default)]
|
||||
pub method: Option<AttestMethodParam>,
|
||||
/// Anything the next reader should know.
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.review_entry`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReviewEntryParams {
|
||||
/// The posted entry reviewed.
|
||||
pub entry_id: u64,
|
||||
/// How the review was made.
|
||||
#[serde(default)]
|
||||
pub method: Option<AttestMethodParam>,
|
||||
/// Anything the next reader should know.
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.attest_period`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AttestPeriodParams {
|
||||
/// The period: its id or its name ("2026-10"), from
|
||||
/// `books.list_periods`.
|
||||
pub period: String,
|
||||
/// How the attestation was made.
|
||||
#[serde(default)]
|
||||
pub method: Option<AttestMethodParam>,
|
||||
/// Anything the next reader should know.
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// One attestation, and whether it counts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AttestationOut {
|
||||
/// Its journal line.
|
||||
pub seq: u64,
|
||||
/// receipt, entry or period.
|
||||
pub kind: String,
|
||||
/// Who made it: the token's name.
|
||||
pub by: String,
|
||||
/// When, UTC.
|
||||
pub at: String,
|
||||
/// The attachment, for a receipt.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attachment_id: Option<u64>,
|
||||
/// The entry, for a receipt or an entry review.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_id: Option<u64>,
|
||||
/// The period, for a period attestation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub period_id: Option<u64>,
|
||||
/// The audit run, for a period attestation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub audit_run_id: Option<u64>,
|
||||
/// The amount read off the receipt.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub amount_checked: Option<String>,
|
||||
/// The date read off the receipt.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date_checked: Option<String>,
|
||||
/// True when the reading differs from the entry's amount or date: it
|
||||
/// never counts.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub disagrees: bool,
|
||||
/// How the client says it checked (self-reported; the books cannot
|
||||
/// verify it).
|
||||
pub method: AttestMethodParam,
|
||||
/// The note.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
/// Whether it meets `policy.attestation_bar` as the bar stands now.
|
||||
pub counted: bool,
|
||||
}
|
||||
|
||||
/// Result of `books.attest_receipt`, `books.review_entry` and
|
||||
/// `books.attest_period`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AttestOut {
|
||||
/// What was recorded.
|
||||
pub attestation: AttestationOut,
|
||||
/// The bar it was weighed against.
|
||||
pub bar: String,
|
||||
/// Why it does not count, when it does not.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub why_not_counted: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.attestations`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AttestationsParams {
|
||||
/// Only attestations of entries dated, or periods ending, on or
|
||||
/// before this day.
|
||||
#[serde(default)]
|
||||
pub through: Option<String>,
|
||||
/// Only this kind: receipt, entry or period.
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.attestations`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AttestationsOut {
|
||||
/// The bar `counted` is weighed against, as it stands now.
|
||||
pub bar: String,
|
||||
/// The attestations, oldest first.
|
||||
pub attestations: Vec<AttestationOut>,
|
||||
/// How many count.
|
||||
pub counted: usize,
|
||||
}
|
||||
Reference in new issue
Block a user