P2 slice 5: files in and out
Files reach the books through the server: uploads by hash and size, then filed; exports (the CPA package, the 1099 worksheet, W-9s) handed over as downloads from the server, never read off disk by the agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
d1f3ad891a
commit
b26c099a68
44 files changed
+5075
-2739
No files matched your search
+190
-111
@@ -191,6 +191,57 @@ settings-and-conventions section says how to use them.
|
||||
binding a LAN, tailnet or public interface is an explicit human
|
||||
decision, never a default. Auth, scopes and Origin validation arrive
|
||||
with the served books (`plan/served.md`, P2).
|
||||
- **Served books** (`wanderbooks server`, started by the host; an agent
|
||||
connects with `wanderbooks mcp --book ID`, a stdio bridge): every book
|
||||
in the data root, on a socket in the root's private `run/` folder that
|
||||
only this account can connect to. Every request carries a bearer token
|
||||
live for that book (refused 401 without one, 403 with another book's).
|
||||
Refusals at the door, tool calls, uploads and downloads are recorded
|
||||
in the book's access log; when a record can't be written the book
|
||||
stops taking requests (an upload or download is then refused, 503).
|
||||
- `/b/{book}/mcp`: the book's MCP service. A request is at most 1 MiB.
|
||||
- `PUT /b/{book}/u/{upload}`: an upload's bytes (`books.begin_upload`),
|
||||
from the token that began it; what `wanderbooks upload --book B
|
||||
[--token NAME] UPLOAD FILE` sends. Streamed to the disk, kept only if
|
||||
exactly the size and SHA-256 declared (so at most 25 MiB), once. The
|
||||
receive and its access record are one blocking task: bytes are never
|
||||
kept without their record (if it can't be written they are thrown
|
||||
away, 503 `not-recorded`, and the books stop). 200 with `{ received,
|
||||
size, sha256 }`; else, with nothing kept and the code in
|
||||
`x-wanderbooks-refused`: 404 `no_such_upload` (unknown or lapsed,
|
||||
also if it lapsed while being sent), 403 `not_yours`, 409
|
||||
`already_sent` (its bytes came, or a call holds them), 409 `in_use`
|
||||
(another request is sending them), 400 `not_as_declared` (short, more than declared, or
|
||||
the wrong hash), 503 `stopped` (the books stopped after an internal
|
||||
error), 500 `failed`. A refused request's body is read and dropped
|
||||
(up to 25 MiB), so the client gets the answer, not a cut connection.
|
||||
Logged as action `upload` with the upload's id, a refusal at the
|
||||
door (no token, another book's) too. An upload's id is `up_` and 32
|
||||
letters `a`-`p`: no digits, so the access log's TIN masking never
|
||||
hides it.
|
||||
- `GET /b/{book}/a/{attachment}`: a stored file's bytes, for a token
|
||||
with `books:read` (403 `insufficient_scope` without it; 404
|
||||
`no_such_attachment`); the read and its access record are one
|
||||
blocking task. Sent with `Content-Type` (the stored MIME type, or
|
||||
`application/octet-stream` if it can't be a header),
|
||||
`Content-Disposition: attachment; filename="…"; filename*=UTF-8''…`
|
||||
(the plain name: anything but visible ASCII and spaces, and `"` and
|
||||
`\`, made `_`; then the whole name, RFC 8187 percent-encoded),
|
||||
`X-Content-Type-Options: nosniff`, and `Repr-Digest:
|
||||
sha-256=:<base64>:` (RFC 9530). A stored file that can't be read
|
||||
whole is 500 `failed`; books that stopped, 503 `stopped`. GET only
|
||||
(a HEAD is 405, unrecorded). Logged as
|
||||
action `download` with the attachment's id, a refusal at the door
|
||||
too.
|
||||
`wanderbooks download --book B [--token NAME] ATTACHMENT --to DIR`
|
||||
asks for it, checks the digest before saving, and saves it under the
|
||||
server's name (`filename*` first) made plain: separators and control
|
||||
characters made `_`, invisible (Unicode format) characters and
|
||||
leading dots and spaces dropped, at most 200 bytes with the extension
|
||||
kept. Beside a file of that name it is `name (1).ext`, and so on;
|
||||
`attachment-N` if the folder takes no such name. It is written to a
|
||||
hidden file and hard-linked in: never a partial file under the real
|
||||
name, never over another file.
|
||||
|
||||
## Server instructions
|
||||
|
||||
@@ -256,7 +307,7 @@ No text the agent is given (tool JSON with its schemas, resources, connect
|
||||
text, handbook, and every refusal fixed in code: each error variant's
|
||||
template in the crates under the server, scanned from source, built error
|
||||
texts, and the server's refusal constants) names a helper command, a home
|
||||
path, the data dir or its flag, the import-root flag, a file of the books
|
||||
path, the data dir or its flag, the old import-root flag, a file of the books
|
||||
(`.jsonl`, the database, `vault.key`, `.gitignore`), git, GitHub, a pull
|
||||
request, a repo doc (`docs/`, `.md`), or tells the agent to read the
|
||||
books' files; no tool title or description, connect text or handbook
|
||||
@@ -614,14 +665,17 @@ texts are at most 500 characters, no control characters.
|
||||
`party.role_set`. Removing the last 1099 role with W-9 facts on file, or
|
||||
employee with W-4 facts, is refused: clear the facts first.
|
||||
- `books.set_w9 { party, received_on, entity_type, exempt_payee_code?,
|
||||
fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_file_path? }` or
|
||||
`{ party, clear: true }` → the party. Journaled as `party.w9_set`. A
|
||||
party with a vendor record also gets the record's `tin_on_file` set to
|
||||
match (journaled as `vendor.1099_set`), so `books.vendor_1099`'s
|
||||
`missing_w9` follows. `tin`, `tin_type`, and `w9_file_path` put the full
|
||||
TIN and the signed form in the TIN vault (below); with them `tin_last4`
|
||||
comes from the TIN (a given one must agree) and `kept_at` defaults to
|
||||
`"wanderbooks vault"`.
|
||||
fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_upload_id?,
|
||||
w9_content_base64?, w9_sha256?, w9_file_name? }` or `{ party, clear:
|
||||
true }` → the party. Journaled as `party.w9_set`. A party with a
|
||||
vendor record also gets the record's `tin_on_file` set to match
|
||||
(journaled as `vendor.1099_set`), so `books.vendor_1099`'s
|
||||
`missing_w9` follows.
|
||||
`tin` and `tin_type` put the full TIN in the TIN vault, and the signed
|
||||
form (`w9_upload_id`, or `w9_content_base64` with `w9_sha256`, as in
|
||||
"Tools — receipts & documents") goes there too (below); with them
|
||||
`tin_last4` comes from the TIN (a given one must agree) and `kept_at`
|
||||
defaults to `"wanderbooks vault"`.
|
||||
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
|
||||
true }` → the party. Journaled as `party.w4_set`.
|
||||
- `books.set_entry_party { entry_id, party?, parties?, no_party_reason? }`
|
||||
@@ -749,56 +803,58 @@ and the CPA package carries them.
|
||||
facts name another last four is refused (a payee's new TIN comes with
|
||||
its W-9: `books.set_w9`); one whose W-9 facts agree gets their
|
||||
`vault_record` updated too (`party.w9_set`).
|
||||
- `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates
|
||||
the TIN against its type (nine digits, hyphens allowed; an ITIN starts
|
||||
with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start
|
||||
with 9), checks the facts on a copy of the ledger, then writes the vault
|
||||
and journals the facts with `vault_record`, the SHA-256 of the TIN
|
||||
record (of the form's record when only a form is given). With a TIN it
|
||||
also journals the party's TIN facts (`party.tin_set`, as
|
||||
`books.set_tin`); a TIN with a new last four clears the old TIN facts
|
||||
first (`party.tin_set` null), so the two never disagree. The form (PDF,
|
||||
PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment
|
||||
store. `w9_file_path` is read exactly as `books.import_local_file` reads
|
||||
a path (the import roots, the sealed places, no links followed out of
|
||||
them), and its type is judged by the file the path really leads to.
|
||||
Facts given later without a TIN keep the vault record when their
|
||||
last four agree with the vault's TIN, and are refused when they don't
|
||||
(pass the new TIN, or purge). Clearing the facts (`clear: true`) is
|
||||
refused while the vault holds the party's TIN or form: purge first.
|
||||
- `books.set_w9 { ..., tin, tin_type, w9_upload_id? | w9_content_base64? +
|
||||
w9_sha256?, w9_file_name? }` (above): validates the TIN against its type (nine digits,
|
||||
hyphens allowed; an ITIN starts with 9 and has an IRS group in digits
|
||||
4-5; an SSN or EIN doesn't start with 9), checks the facts on a copy of
|
||||
the ledger, then writes the vault and journals the facts with
|
||||
`vault_record`, the SHA-256 of the TIN record (of the form's record when
|
||||
only a form is given). With a TIN it also journals the party's TIN facts
|
||||
(`party.tin_set`, as `books.set_tin`); a TIN with a new last four clears
|
||||
the old TIN facts first (`party.tin_set` null), so the two never
|
||||
disagree. The form (a PDF, PNG or JPEG, judged by its own first bytes,
|
||||
whatever it is called; an upload of purpose `w9`, at most 25 MiB, or
|
||||
inline at most 256 KiB) goes to the vault only, never the attachment
|
||||
store. Its file name is `w9_file_name` if given, else the upload's,
|
||||
else `w9.pdf`, `w9.png` or `w9.jpg` by its type, checked as any filed
|
||||
name; `w9_file_name` without a W-9 is refused. Facts given later without a TIN
|
||||
keep the vault record when their last four agree with the vault's TIN,
|
||||
and are refused when they don't (pass the new TIN, or purge). Clearing
|
||||
the facts (`clear: true`) is refused while the vault holds the party's
|
||||
TIN or form: purge first.
|
||||
- `books.get_w9 { party, save_to }` → `{ party_id, path, file_name,
|
||||
size_bytes, note }`. Writes the party's form from the vault to `save_to`:
|
||||
a new file (an existing one is refused), in a folder that exists,
|
||||
outside the books dir and outside the import roots, mode 0600. Returns
|
||||
the path, never the contents. Journals nothing. The same holds for
|
||||
`books.vendor_1099`'s `save_csv_to` with `include_tins` and
|
||||
`books.export_cpa_package`'s `save_to` with TINs: a file with full TINs
|
||||
never lands where it could be filed back in. Nor does one go in from
|
||||
elsewhere: the server keeps, beside the vault, the SHA-256 of every file
|
||||
it read or wrote with full TINs (the W-9 `books.set_w9` reads, recorded
|
||||
once the call has passed every check, just before the vault keeps it,
|
||||
so a refused call marks no file; each of these outputs; each file inside a CPA package
|
||||
that holds a TIN), shared by every session and kept across restarts,
|
||||
and every filing tool (`books.import_local_file`, `books.attach_receipt`,
|
||||
`books.file_document`) refuses those exact files and any text file
|
||||
(CSV, TSV, text, JSON, IIF, XML, HTML, Markdown, by type, name or UTF-8
|
||||
or UTF-16 decoding, UTF-16 with a byte-order mark or NUL-interleaved
|
||||
without one) holding a full TIN, storing nothing. A text file
|
||||
holds one when it has a TIN the vault holds, anywhere, as nine digits,
|
||||
3-2-4 joined by hyphens, spaces or dots, or 2-7 joined by a hyphen or a
|
||||
space (compared on the server; a vault that can't be opened leaves the
|
||||
next test alone), or a stand-alone number printed as an SSN or EIN is
|
||||
size_bytes, note }`. Writes the party's form from the vault to
|
||||
`save_to`: a new file (an existing one is refused), in a folder that
|
||||
exists, outside the books dir (where it really leads, every link
|
||||
followed), mode 0600. Returns the path, never the contents. Journals
|
||||
nothing. The same holds for `books.vendor_1099`'s `save_csv_to` with
|
||||
`include_tins` and `books.export_cpa_package`'s `save_to` with TINs: a
|
||||
file with full TINs never lands inside the books. Nor does one go in
|
||||
from elsewhere: the server keeps, beside the vault, the SHA-256 of every
|
||||
file it read or wrote with full TINs (the W-9 `books.set_w9` reads,
|
||||
recorded once the call has passed every check, just before the vault
|
||||
keeps it, so a refused call marks no file; each of these outputs; each
|
||||
file inside a CPA package that holds a TIN), shared by every session and
|
||||
kept across restarts, and every filing tool (`books.attach_receipt`,
|
||||
`books.file_document`) refuses those exact files and any text file (CSV,
|
||||
TSV, text, JSON, IIF, XML, HTML, Markdown, by type, name or UTF-8 or
|
||||
UTF-16 decoding, UTF-16 with a byte-order mark or NUL-interleaved
|
||||
without one) holding a full TIN, storing nothing. A text file holds one
|
||||
when it has a TIN the vault holds, anywhere, as nine digits, 3-2-4
|
||||
joined by hyphens, spaces or dots, or 2-7 joined by a hyphen or a space
|
||||
(compared on the server; a vault that can't be opened leaves the next
|
||||
test alone), or a stand-alone number printed as an SSN or EIN is
|
||||
(123-45-6789, 12-3456789; no digit, and no hyphen or dot followed by a
|
||||
digit, beside it). Bank exports pass: routing and account numbers,
|
||||
long references and dates next to amounts are not TINs (rules.md B-26).
|
||||
No probing: a text with more than two TIN-shaped numbers ending in the
|
||||
last four of a TIN the books know is refused before the vault is
|
||||
opened, in the same words whether or not one is that TIN, so the vault
|
||||
is compared against at most two candidates a filing (rules.md B-26).
|
||||
An altered copy of a binary file (a W-9 PDF re-saved or re-scanned),
|
||||
and a TIN the vault doesn't hold written another way, are not
|
||||
detected: P2 and P7 take the path tools and full TINs out of the
|
||||
agent's reach.
|
||||
digit, beside it). Bank exports pass: routing and account numbers, long
|
||||
references and dates next to amounts are not TINs (rules.md B-26). No
|
||||
probing: a text with more than two TIN-shaped numbers ending in the last
|
||||
four of a TIN the books know is refused before the vault is opened, in
|
||||
the same words whether or not one is that TIN, so the vault is compared
|
||||
against at most two candidates a filing (rules.md B-26). An altered copy
|
||||
of a binary file (a W-9 PDF re-saved or re-scanned), and a TIN the vault
|
||||
doesn't hold written another way, are not detected: P2 slice 6 moves the
|
||||
TIN outputs (`save_to`, `save_csv_to`) to the viewer, and P7 takes full
|
||||
TINs out of the agent's reach.
|
||||
- `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" |
|
||||
"purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?,
|
||||
journaled, note }`. Without `confirmed`, a preview that changes nothing.
|
||||
@@ -841,10 +897,56 @@ the receipt policy counts it.
|
||||
|
||||
## Tools — receipts & documents
|
||||
|
||||
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64,
|
||||
description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links
|
||||
}`. `entry_id` is optional: a receipt can be filed before its entry
|
||||
exists. `link_to` as in "Tools — attachments".
|
||||
A file comes in one of two ways, named in the tool that files it
|
||||
(`books.attach_receipt`, `books.file_document`, `books.set_w9`,
|
||||
`books.import_bank_csv`, `books.import_chart_csv`): `upload_id`, an
|
||||
upload made with `books.begin_upload` (any file up to 25 MiB; its bytes
|
||||
never pass through the agent), or `content_base64` with its `sha256`
|
||||
(at most 256 KiB decoded; longer text is refused before it is decoded).
|
||||
Never both, never neither. Inline bytes that aren't base64, or whose
|
||||
SHA-256 isn't the one given, are refused (guard `base64_corrupt`): send
|
||||
the file as an upload. Uploads exist only for books served from the
|
||||
data root (the server's socket carries the bytes); over stdio, only
|
||||
inline, and the refusal says so and to tell the human. On the socket an
|
||||
MCP request is at most 1 MiB. A file's name and type are checked when
|
||||
it is filed (every filing tool, `books.begin_upload`, and the W-9's
|
||||
name): a name of 1 to 255 bytes, with no `/` or `\`, not `.` or `..`,
|
||||
and no control or invisible (Unicode format) characters; a MIME type
|
||||
`type/subtype` (parameters allowed) of visible ASCII, at most 127 bytes.
|
||||
|
||||
- `books.begin_upload { sha256, size, mime_type, file_name, purpose:
|
||||
"receipt" | "document" | "import" | "w9" }` → `{ upload_id?, command?,
|
||||
expires_at?, max_bytes, existing_attachment_id? }`. Makes a grant for
|
||||
the calling token: one SHA-256, one size (at most 25 MiB; empty
|
||||
refused), one purpose, lasting 15 minutes; at most 16 under way per
|
||||
token. A tool takes only an upload of its own purpose:
|
||||
`books.attach_receipt` and `books.file_document` take `receipt` or
|
||||
`document`, the CSV imports `import`, `books.set_w9` `w9`; another
|
||||
tool refuses it and leaves it for the right one. `command` is
|
||||
`wanderbooks upload --book B <upload_id> -- <the file's path>` (with
|
||||
`--token NAME` when the caller's token isn't the default `agent`),
|
||||
which sends the bytes (`PUT /b/{book}/u/{upload}`, "Transports"); the
|
||||
agent runs it, then names `upload_id` in the tool that files it.
|
||||
Bytes that aren't the size and SHA-256 declared are refused and
|
||||
nothing is kept. An upload's bytes are sent once, only by the token
|
||||
that began it, and used once: the call that takes them holds them
|
||||
(hashed again) and spends them only when they are in the books (an
|
||||
import: once its rehearsal passed); a call refused after taking them,
|
||||
or one that panics, gives them back, and a server stopped mid-call
|
||||
gives them back when the books next open. Lapsed grants are refused,
|
||||
and swept when the books open, whenever a grant is made, and every
|
||||
10 seconds. For `receipt` or `document`, when an attachment not withdrawn
|
||||
already holds those bytes, no grant is made: `existing_attachment_id`
|
||||
names it (link it, `books.link_attachment`, instead of filing a copy);
|
||||
for `import` and `w9` a grant is always made. Refusals never name a
|
||||
path.
|
||||
- `books.attach_receipt { entry_id?, file_name?, mime_type?, upload_id? |
|
||||
content_base64? + sha256?, description?, link_to? }` → `{ attachment_id,
|
||||
sha256, deduplicated, links }`. `entry_id` is optional: a receipt can be
|
||||
filed before its entry exists. `file_name` and `mime_type` default to
|
||||
the upload's; inline they are required. The upload is taken only after
|
||||
the entry and every `link_to` subject check out. `link_to` as in "Tools
|
||||
— attachments".
|
||||
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
||||
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
||||
must be unlinked first; one already linked to that entry
|
||||
@@ -865,15 +967,23 @@ the receipt policy counts it.
|
||||
subject it is linked to, its filing entry first). Withdrawn attachments
|
||||
are omitted unless `include_withdrawn` is true. `entry_id` lists the
|
||||
files filed on the entry and linked to it.
|
||||
- `books.get_receipt { attachment_id }` → `{ metadata, description?,
|
||||
links, content_base64 }`.
|
||||
- `books.get_receipt { attachment_id, view?: "meta" | "bytes" }` → `{
|
||||
metadata, description?, links, content_base64?, download? }`. `meta`
|
||||
(the default) returns no bytes. `bytes` adds `content_base64`
|
||||
(hash-verified) for a file of at most 256 KiB; a larger one gives
|
||||
`download`, the command `wanderbooks download --book B <id> --to <a
|
||||
folder>` (with `--token NAME` when the caller's token isn't the
|
||||
default `agent`; `GET /b/{book}/a/{attachment}`, "Transports"), which
|
||||
saves a copy as a new file in that folder. Over stdio there is no
|
||||
download: a larger file is refused.
|
||||
- `books.file_document { category: "formation" | "legal" | "tax" |
|
||||
"accounting" | "statement" | "contract" | "photo" | "warranty" |
|
||||
"import_source" | "other", file_name, mime_type, content_base64,
|
||||
description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links
|
||||
}`. The Drive-folder replacement (today's 01 Formation / 02 Legal / 03
|
||||
Tax / 04 Accounting). Never a W-9: it shows the full TIN (`books.set_w9
|
||||
w9_file_path`).
|
||||
"import_source" | "other", file_name?, mime_type?, upload_id? |
|
||||
content_base64? + sha256?, description?, link_to? }` → `{ attachment_id,
|
||||
sha256, deduplicated, links }`. The bytes, name and type as in
|
||||
`books.attach_receipt`. The Drive-folder replacement (today's 01
|
||||
Formation / 02 Legal / 03 Tax / 04 Accounting). Never a W-9: it shows
|
||||
the full TIN (`books.set_w9 w9_upload_id`).
|
||||
|
||||
## Receipt enforcement
|
||||
|
||||
@@ -977,51 +1087,19 @@ posting.
|
||||
|
||||
## Tools — import
|
||||
|
||||
- `books.import_bank_csv { csv_text, bank_account_code, default_offset_code,
|
||||
rules?, date_order?, source_attachment_id? }` → creates one draft per
|
||||
transaction (QBO upload layout). `rules` maps description substrings to
|
||||
account codes. `source_attachment_id` (the export as filed, category
|
||||
- `books.import_bank_csv { csv_text? | upload_id?, bank_account_code,
|
||||
default_offset_code, rules?, date_order?, source_attachment_id? }` →
|
||||
creates one draft per transaction (QBO upload layout). `rules` maps
|
||||
description substrings to account codes. `source_attachment_id` (the export as filed, category
|
||||
`import_source`) is checked before any draft and linked to the bank
|
||||
account (`source_linked`; false when it was already), never to the
|
||||
drafts: a bank export is not a receipt.
|
||||
- `books.import_chart_csv { csv_text }` → creates accounts from
|
||||
`code,name,type,parent_code` CSV. All-or-nothing.
|
||||
- `books.import_local_file { path, category: "receipt" | "document", ... }`
|
||||
→ files a file from the server's own disk, read only from the **import
|
||||
roots**: each `--import-root DIR` given to `ledger-mcp` (repeatable), or
|
||||
by default the downloads folder only (`$XDG_DOWNLOAD_DIR`, else
|
||||
`~/Downloads`). No root may be `/`, the home folder or a folder holding
|
||||
it, a folder holding the books dir, the data dir (where a symlinked one
|
||||
really is), the vault or a vault key, or a folder inside the books dir,
|
||||
the data dir or the vault (every file there would be refused): a named
|
||||
root like that stops
|
||||
the server at startup, saying why; a default one is dropped. The default
|
||||
is used only when it is a real folder that isn't the home folder (an
|
||||
`XDG_DOWNLOAD_DIR` of `$HOME/` falls back to `~/Downloads`, and to no
|
||||
root when that doesn't exist; an import then says the server has no
|
||||
import folder). The path must be absolute, with no `.` or `..` name,
|
||||
and where it leads (every link followed) a regular file strictly inside
|
||||
a root, with one hard link. Refusals never name a path and never probe:
|
||||
a path that is missing, a dangling link, outside the roots, or with a
|
||||
`.` or `..` name (refused before anything is looked up: `a/b/..`
|
||||
resolves only when `b` is a folder) gets one refusal, "no
|
||||
such file in the server's import folders (by default your downloads
|
||||
folder): save it there first". Inside a root, the books' sealed places
|
||||
(the data dir, the vault, the key file in force and the default one,
|
||||
where each leads, `~/.config/wanderbooks`, and the key files' folders
|
||||
unless that folder is the home folder, a root, or holds one) are refused
|
||||
as "belongs to the books or the TIN vault"; anything else in the books
|
||||
dir is refused with "save a copy of it in the server's import folders
|
||||
first". The file is opened by a walk from `/`, one name at a time, each
|
||||
folder with `openat(O_DIRECTORY | O_NOFOLLOW)` and the file with
|
||||
`O_NOFOLLOW | O_NONBLOCK`, so a folder on the path swapped for a link
|
||||
after the check fails the open and a pipe can't hang the server; no step
|
||||
may land on a protected place by device and inode (one renamed into a
|
||||
root), and the file opened must be the checked one (device, inode, one
|
||||
link). It is filed under the name and type of the file the path leads
|
||||
to. Files holding full TINs are refused as above
|
||||
(`ledger-mcp/src/local_files.rs`, `tin_files.rs`). Deleted in P2
|
||||
(uploads replace it).
|
||||
- `books.import_chart_csv { csv_text? | upload_id? }` → creates accounts
|
||||
from `code,name,type,parent_code` CSV. All-or-nothing.
|
||||
- Each takes the CSV as `csv_text` or as an upload (`books.begin_upload`
|
||||
purpose `import`; UTF-8), never both. The CSV itself is not filed: file
|
||||
it with `books.file_document` (category `import_source`) for
|
||||
`source_attachment_id`.
|
||||
|
||||
## Resources (read-only)
|
||||
|
||||
@@ -1036,7 +1114,8 @@ Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
|
||||
this month so far
|
||||
- `books://handbook` (the index) and `books://handbook/{section}` (one per
|
||||
section, Markdown)
|
||||
- `books://receipts/{id}` — receipt metadata + base64 bytes
|
||||
- `books://receipts/{id}` — receipt metadata (as `books.get_receipt`
|
||||
view `meta`; no bytes)
|
||||
|
||||
Resources exist so read-only clients (dashboards, the future read-only GUI,
|
||||
auditors) never need tool credentials. Tested end-to-end over stdio.
|
||||
|
||||
+3
-2
@@ -57,8 +57,9 @@ version refuses books from a newer one as newer, not corrupt (rules.md
|
||||
B-27); the MCP server fails closed after an internal error, and no input
|
||||
reaches one (dates are four-digit years, amounts at most a line's, B-28);
|
||||
local files
|
||||
are read only from the import roots, never the books, the vault or its
|
||||
key; no file with a full TIN is filed into the books (B-26); the agent is
|
||||
were read only from the import roots, never the books, the vault or its
|
||||
key (P2 slice 5 removed local paths: files now come in as uploads or
|
||||
inline bytes); no file with a full TIN is filed into the books (B-26); the agent is
|
||||
told the rules in force in a short connect text and a handbook, and no
|
||||
agent text points at the books' files, git or the repo.
|
||||
|
||||
|
||||
@@ -275,8 +275,9 @@ damn CPA package."
|
||||
open and verify; the 1099 worksheet and the CPA package then say which
|
||||
TINs are missing (`tin_gaps`), and the package is still built.
|
||||
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
|
||||
`books.set_w9` takes `tin` + `tin_type` and `w9_file_path` (the form
|
||||
goes to the vault, never the attachment store) and journals the last
|
||||
`books.set_w9` takes `tin` + `tin_type` and the signed form as
|
||||
`w9_upload_id` (`books.begin_upload`) or `w9_content_base64` with
|
||||
`w9_sha256` (the form goes to the vault, never the attachment store) and journals the last
|
||||
four with `vault_record`; facts that disagree with the vault's TIN are
|
||||
refused, and clearing facts with a vault record needs a purge first.
|
||||
With a TIN it also records the party's TIN facts. `books.set_tin
|
||||
|
||||
@@ -89,8 +89,13 @@ before backups.
|
||||
- **Deduplicated.** Attaching the same receipt twice stores one blob and two
|
||||
attachment rows. `attach_receipt` / `file_document` return
|
||||
`deduplicated: true` when the blob already existed.
|
||||
- **Limits.** 25 MB per file by default, configurable. MIME type is accepted
|
||||
from the client and stored; server-side sniffing is future work.
|
||||
- **Limits.** 25 MiB per file as an upload (`books.begin_upload`), 256
|
||||
KiB given inline. The name and MIME type are the client's, checked
|
||||
when the file is filed: a name of 1 to 255 bytes, no `/` or `\`, not
|
||||
`.` or `..`, no control or invisible characters; a `type/subtype` of
|
||||
visible ASCII, at most 127 bytes (`docs/mcp-interface.md`, "Tools —
|
||||
receipts & documents"). The type is stored as given; server-side
|
||||
sniffing is future work (only a W-9 is judged by its first bytes).
|
||||
- **Metadata** lives in SQLite (`blobs` table), not in the filesystem:
|
||||
`sha256 PK, size_bytes, mime_type, original_name, created_at`.
|
||||
|
||||
|
||||
+7
-6
@@ -583,10 +583,11 @@ asked): a refusal that quotes its input masks any TIN in it
|
||||
and a party text field refuses nine digits however they are separated
|
||||
(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `save_csv_to`, and the CPA
|
||||
package, whose K-1 and 1099 worksheets carry every member's and payee's
|
||||
TIN by default) are written only outside the books dir and outside the
|
||||
server's import folders (DECISION, P0 review, 2026-10-16: so none can be
|
||||
filed back in as a receipt and read out again), and the package comes
|
||||
back only as its path. No file holding a full TIN is filed into the books
|
||||
TIN by default) are written only outside the books dir (DECISION, P0
|
||||
review, 2026-10-16: so none can be filed back in as a receipt and read
|
||||
out again; the server's import folders, refused then too, went with
|
||||
`books.import_local_file` in P2 slice 5), and the package comes back
|
||||
only as its path. No file holding a full TIN is filed into the books
|
||||
(DECISION, P0 review round 2, 2026-10-16): the server keeps, beside the
|
||||
vault, the SHA-256 of every file it read or wrote with full TINs (the W-9
|
||||
`books.set_w9` reads, recorded once the call has passed every check, just
|
||||
@@ -594,8 +595,8 @@ before the vault keeps it, so a refused call marks no file; each
|
||||
`books.get_w9` copy;
|
||||
a 1099 worksheet with TINs; a CPA package with TINs and each file in it
|
||||
that holds one), shared by every session and every later server for the
|
||||
books, and every way a file enters the books (`books.import_local_file`,
|
||||
`books.attach_receipt`, `books.file_document`) refuses those exact files,
|
||||
books, and every way a file enters the books (`books.attach_receipt`,
|
||||
`books.file_document`, an upload or inline) refuses those exact files,
|
||||
and refuses any text file (by its type or name, or because it decodes as
|
||||
UTF-8 or UTF-16, with a byte-order mark or, NUL-interleaved, without one)
|
||||
holding (a) a TIN the vault holds for a party of the
|
||||
|
||||
+17
-19
@@ -65,13 +65,10 @@ that would fail if it broke:
|
||||
| No input reaches a panic (each would stop the server for every session): every tool with arguments generated from its schema and made hostile (wrong types, u64::MAX, decimals of 30+ digits or absurd exponents, dates like 0000-00-00, 99999-12-31, +262142-12-31, 2026-02-30, periods like 999999-01, control and format characters, long strings), every resource URI generated; the inputs that once panicked, pinned; after each property the server restarts and the books reopen (a call a command accepts but replay refuses would brick them); every tool and resource over every old book (rules.md B-28) | `ledger-mcp` `tests/hostile_args.rs` `hostile_tool_arguments_never_stop_the_server` (with a floor: most calls reach a tool, some succeed), `hostile_resource_uris_never_stop_the_server`, `inputs_that_once_panicked_are_refused_or_answered`, `old_books_answer_every_tool`; `MCP_CASES` and `OLD_BOOKS_CASES` (default 3 per tool per book) set the cases |
|
||||
| Dates are in 0001-01-01 through 9999-12-31 and amounts at most a line's, for every new command; replay accepts every line an earlier version accepted (each bound has its pair: the command refuses past it, the replayed old line is accepted); a figure derived past exact arithmetic or chrono's calendar is refused, never a panic; a recurring run makes at most 1,000 drafts (rules.md B-28) | `ledger-core` `tests/spec_bounds.rs`, `calendar.rs` tests, `plans_at_the_edges_are_computed_or_refused`, `intervals_reach_chronos_last_day`, `a_vendor_part_is_scaled_exactly_or_refused`, the `share_property` cases; `ledger-store` `tests/bounds.rs`, `tests/store.rs` (the old-books tests); `server.rs` `periods_are_a_year_and_a_month`, `dates_are_four_digit_years_and_real_days` |
|
||||
| Opening the books changes nothing until every complete line passes in memory (chain, gate, first line, replay); the viewer applies the writer's first-line rule (rules.md B-27) | `format.rs` `a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was`, `the_first_line_is_store_created_for_the_viewer_and_the_writer`; `fuzz_store_replay` (the first line varies) |
|
||||
| A path with a `.` or `..` name is refused as missing before anything is looked up; a root inside the books, the data dir or the vault is refused at startup (dropped as the default) | `local_files.rs` `a_dot_or_dotdot_name_never_tells_whether_folders_outside_exist`, `a_root_holding_the_books_or_the_home_folder_is_refused`; `startup.rs` `an_import_root_holding_the_books_or_the_home_folder_is_refused`, `a_default_download_dir_holding_the_books_is_dropped_not_fatal` |
|
||||
| UTF-16 text without a byte-order mark is scanned like any text; a refused `books.set_w9` marks no file; a refused resource read never echoes a TIN | `tin_files.rs` (tests) `utf16_text_without_a_byte_order_mark_is_scanned_either_way_round`, `a_refused_set_w9_never_marks_its_file`, unit test `only_nul_interleaved_bytes_read_as_utf16_without_a_mark`; `spec_vault.rs` `resource_refusals_never_echo_a_tin` |
|
||||
| idempotentHint only where a repeat with the same arguments leaves the journal and the vault as they were; every tool's hints pinned in one table | `spec_vault.rs` `idempotent_tools_change_nothing_when_repeated`; `annotations_complete` |
|
||||
| books.settings lists every receipt-required and exempt account, uncut; a fresh company is "a new company"; the lint catches `just` however it ends and where the books are | `mcp_stdio.rs` `every_receipt_account_left_out_of_the_connect_text_is_in_books_settings`; `handbook.rs` `a_fresh_company_is_a_new_company`; `tool_text_lint_catches_helpers_and_locations` |
|
||||
| A local file is read only from inside an import root, never a sealed place (data dir, vault, keys, the key folders that don't hold a root), never through a swap after the check (a component walk with `O_NOFOLLOW`, no step onto a protected inode); a missing, dangling or outside path gets one refusal | `local_files.rs` `import_local_file_refuses_key_and_journal`, the property `import_allowed_iff_regular_file_inside_a_root_outside_the_books`, `refusals_never_tell_what_is_there`, `a_file_swapped_after_the_check_is_never_read`, `a_folder_swapped_after_the_check_is_never_walked_through` (the reviewer's race, without `/proc`), `resolved_follows_every_link_a_dangling_one_too`, `sealing_a_key_never_seals_a_whole_root`; through the tools, `mcp_stdio.rs` `import_local_file_and_set_w9_refuse_the_key_vault_and_journal_inside_a_root` |
|
||||
| No import root is `/`, the home folder or holds it, or holds the books, data dir, vault or a key; the default is a real downloads folder or none | `local_files.rs` `the_default_root_is_never_the_home_folder_or_the_filesystem`, `a_root_holding_the_books_or_the_home_folder_is_refused`; `startup.rs` `an_import_root_holding_the_books_or_the_home_folder_is_refused`, `a_download_dir_of_home_never_opens_the_home_folder` |
|
||||
| A file with full TINs is never written into an import root, and never filed into the books: every file the server read or wrote with TINs (from any session, after a restart, out of a CPA package), by every filing tool | `mcp_stdio.rs` `tin_bearing_files_never_land_where_they_could_be_filed_back_in`; `tin_files.rs` (tests) `a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`; unit test `a_recorded_tin_file_is_refused_by_every_reader_of_the_registry` |
|
||||
| A file with full TINs is never written inside the books, and never filed into the books: every file the server read or wrote with TINs (from any session, after a restart, out of a CPA package), by every filing tool | `mcp_stdio.rs` `tin_bearing_files_never_land_where_they_could_be_filed_back_in`; `tin_files.rs` (tests) `a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`; unit test `a_recorded_tin_file_is_refused_by_every_reader_of_the_registry` |
|
||||
| Text filed into the books is refused when it holds a TIN the vault keeps (nine digits, 3-2-4 by hyphen, space or dot, 2-7 by hyphen or space, anywhere) or a stand-alone printed SSN or EIN (123-45-6789, 12-3456789), and only then: a bank export (routing numbers, long references, a date next to an amount) and a longer number holding an SSN shape go in; a locked vault leaves the printed shapes (rules.md B-26) | `tin_files.rs` (tests) `text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not`; unit tests `text_holding_a_vault_tin_is_refused_in_every_rendering`, `a_printed_ssn_or_ein_is_refused`, `a_longer_number_holding_a_tin_shape_passes`, `an_ordinary_bank_export_passes`, `binary_bytes_are_not_scanned` |
|
||||
| Filing is no TIN oracle: text with more than two TIN-shaped numbers ending in a known TIN's last four (the books' TIN and W-9 facts, then the vault's own) is refused before the vault is opened, byte for byte alike whether one is the vault's TIN or none is, the vault open or locked, by every filing tool; one or two such numbers are compared against the vault, one number written many ways counts once, and other last fours never count (rules.md B-26) | `tin_files.rs` (tests) `text_probing_for_a_tin_is_refused_alike_by_every_filing_tool`; unit tests `text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it` (the vault never opened for a probe), `one_or_two_candidates_are_compared_against_the_vault` |
|
||||
| The connect text fits its budgets, its lead stands alone, it states the receipt rule enforced (exact threshold in the base currency, required and exempt accounts, reversals) and the parties rule in force, and the company name can't break it (control and format characters) | `handbook.rs` `instructions_budget`, `a_typical_company_gets_the_whole_connect_text`, `the_company_name_is_one_plain_line`; `receipt_policy.rs` `the_rule_told_is_the_rule_enforced`, `a_long_or_odd_account_list_stays_short_and_plain`; a new session is told the settings in force: `a_new_session_is_told_the_settings_in_force` |
|
||||
@@ -110,19 +107,17 @@ broke, removed by simpler code, or is equivalent:
|
||||
- **Killed (MCP).** A section's title is its heading
|
||||
(`a_title_is_the_heading_text`; over stdio the handbook tool titles a
|
||||
section by its heading and "all" as the whole handbook,
|
||||
`handbook_tool_and_resources_over_stdio`). A refused default import
|
||||
root is dropped, never fatal (`ImportRoots::explicit`:
|
||||
`a_default_download_dir_holding_the_books_is_dropped_not_fatal`). A
|
||||
`.jpg` or `.jpeg` W-9 is taken (`every_w9_file_type_is_taken`). The
|
||||
waker wakes: a request pending until another thread wakes it completes
|
||||
(`a_request_that_waits_is_woken_and_completes`). The walk opens each
|
||||
folder with `O_DIRECTORY | O_NOFOLLOW`: a folder swapped for a link to
|
||||
an unprotected folder outside the roots, or for a pipe, is never walked
|
||||
through and never blocks
|
||||
(`a_folder_swapped_for_a_link_or_a_pipe_is_never_walked_through`). A
|
||||
read failure names its kind, never the path
|
||||
(`cant_read_names_the_failure_not_the_path`). `resolved` follows 40
|
||||
links, as the kernel does, and no more
|
||||
`handbook_tool_and_resources_over_stdio`). A `.jpg` or `.jpeg` W-9
|
||||
was taken (`every_w9_file_type_is_taken`); P2 slice 5 judges the W-9
|
||||
by its first bytes, not its name, so that mutant's code is gone (the
|
||||
test now checks each type by its bytes). The waker wakes: a request pending until another thread wakes it completes
|
||||
(`a_request_that_waits_is_woken_and_completes`). Three more were
|
||||
killed by tests of the import roots and their walk (a refused default
|
||||
root dropped, never fatal; a folder swapped for a link or a pipe never
|
||||
walked through; a read failure naming its kind, never the path): P2
|
||||
slice 5 removed `books.import_local_file` and the import roots, and
|
||||
those tests with them. `resolved` follows 40 links, as the kernel
|
||||
does, and no more
|
||||
(`resolved_follows_forty_links_and_no_more`). A file is saved only to
|
||||
a new file in a folder that exists, and a missing folder or a taken
|
||||
file is refused as such, never as a failed write
|
||||
@@ -154,8 +149,11 @@ broke, removed by simpler code, or is equivalent:
|
||||
file's size before reading, so its read limit (`limit + 1`) was only
|
||||
reached by a file growing during the read; the read's limit is now the
|
||||
one check (the 25 MiB upload test kills `+ 1` → `* 1`). `openat` failed
|
||||
on `fd < 0`, now on `fd == -1`, as openat(2) specifies.
|
||||
- **Equivalent (MCP, all 14 missed).** `^` for `|` in the open flags
|
||||
on `fd < 0`, now on `fd == -1`, as openat(2) specifies. (`inspect`,
|
||||
`read_checked`, the `openat` walk and that 25 MiB test were removed
|
||||
with the import roots in P2 slice 5.)
|
||||
- **Equivalent (MCP, all 14 missed; the walk and its stubs were removed
|
||||
in P2 slice 5).** `^` for `|` in the open flags
|
||||
(six: the walk's first step, each folder's two, the file's two, and
|
||||
`openat`'s `O_CLOEXEC`): `O_RDONLY` is 0 and `O_DIRECTORY`, `O_NOFOLLOW`,
|
||||
`O_NONBLOCK`, `O_CLOEXEC` are distinct bits, so `a ^ b` is `a | b`.
|
||||
|
||||
@@ -238,7 +238,8 @@ was weakened. What it added is checked by tests and properties (listed in
|
||||
never called corrupt, damage always is; the envelope and its hash are
|
||||
frozen across formats, rules.md B-27), the server failing closed after a
|
||||
panic, the import roots' lockdown (a property over generated paths, and a
|
||||
component walk that refuses a folder swapped for a link), files with full
|
||||
component walk that refuses a folder swapped for a link; removed with the
|
||||
import roots in P2 slice 5), files with full
|
||||
TINs kept out of the books (B-26), and the agent text lint. Out of scope
|
||||
for proof today: the format gate and the walk are I/O, and the model in
|
||||
`verify/lean` stops at the ledger.
|
||||
|
||||
Reference in new issue
Block a user