feat: receipt unlink and attachment withdrawal
Posted entries stay immutable (R-3), but their evidence can now change the
same way the ledger does: by appending events, never by editing or
deleting. New R-13a.
- books.unlink_receipt {attachment_id, reason} journals receipt.unlinked
and leaves the receipt filed, unlinked. The receipt policy holds: an
entry that requires a receipt can't lose its last one (structured
receipt_required error), so a replacement is attach-new, then
unlink-old. Drafts and reversals are exempt, as at posting.
- books.withdraw_attachment {attachment_id, reason} journals
attachment.withdrawn for an unlinked receipt or a filed document: hidden
from list_receipts (unless include_withdrawn), never linkable again. The
blob stays in the store, hash-verified and readable.
- link_receipt now refuses an already-linked or withdrawn attachment.
Before, re-linking silently moved a receipt off a posted entry,
bypassing the policy; evidence now leaves an entry only through a
recorded unlink. dispose_asset checks its receipt ids before posting
instead of after.
- Schema v5 adds attachments.withdrawn_at/withdrawn_reason; existing
stores migrate on open and replay rebuilds the projection.
Tests: store test (errors, reopen, rebuild, verify, journal history) and
an MCP stdio test (policy refusal, replace flow, withdraw, listings,
under-threshold unlink). Dry-run and live use on the Wanderland Labs books:
8 migrated .eml attachments swapped for their PDF renders, trial balance
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
1 parent
3bfea47a59
commit
e59e8001c6
13 files changed
+580
-21
No files matched your search
@@ -18,7 +18,7 @@ Statuses: `covered` (built and tested), `partial` (built, known gaps listed),
|
||||
| 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered |
|
||||
| 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | covered |
|
||||
| 6 | Transaction Journal as append-only log | engine posting log + `books.search_entries` | covered |
|
||||
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` | covered |
|
||||
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` / `unlink_receipt` / `withdraw_attachment` | covered |
|
||||
| 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered |
|
||||
| 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered |
|
||||
| 10 | Human review of what the assistant recorded | `books.digest` | covered |
|
||||
|
||||
+16
-2
@@ -117,8 +117,22 @@ so the lifetime total is exactly (cost − salvage).
|
||||
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
|
||||
optional: a receipt can be filed before its entry exists.
|
||||
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
||||
receipt to its entry.
|
||||
- `books.list_receipts { entry_id? }` → metadata list (no bytes).
|
||||
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
||||
must be unlinked first.
|
||||
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
|
||||
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
|
||||
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
|
||||
receipt policy: an entry that requires a receipt cannot be left with none
|
||||
(`receipt_required` error, remedy `books.attach_receipt`), so replacing a
|
||||
receipt is attach-new, then unlink-old.
|
||||
- `books.withdraw_attachment { attachment_id, reason }` → `{ ok }`. Retires
|
||||
an unlinked receipt or a filed document: hidden from listings, never
|
||||
linkable again. Journaled as `attachment.withdrawn`; the bytes stay in the
|
||||
blob store. Nothing in the evidence trail is ever erased — receipts change
|
||||
by new events, the same way entries change by reversals.
|
||||
- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list
|
||||
(no bytes), each with `withdrawn` / `withdrawn_reason`. Withdrawn
|
||||
attachments are omitted unless `include_withdrawn` is true.
|
||||
- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
|
||||
- `books.file_document { category: "formation" | "legal" | "tax" |
|
||||
"accounting" | "other", file_name, mime_type, content_base64,
|
||||
|
||||
+11
-1
@@ -50,7 +50,9 @@ apply is safe — on startup the applier replays every journal line with
|
||||
- `seq`: 1-based, gapless, assigned by the writer.
|
||||
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
|
||||
- `type`: `account.added`, `entry.posted`, `entry.reversed`,
|
||||
`receipt.attached`, `receipt.linked`, `document.filed`.
|
||||
`receipt.attached`, `receipt.linked`, `receipt.unlinked`,
|
||||
`document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset,
|
||||
period, payroll, and class events listed on `JournalLine`).
|
||||
- `payload`: the full domain facts (account fields, entry with lines, blob
|
||||
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
|
||||
- `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
|
||||
@@ -99,6 +101,14 @@ before backups.
|
||||
plus `receipts`.
|
||||
- Documents filed via `books.file_document` and receipts via
|
||||
`books.attach_receipt` share the blob store; they differ only in metadata.
|
||||
- **Receipts change by events, never by edits.** `receipt.unlinked`
|
||||
(`attachment_id`, `entry_id`, `reason`) detaches a receipt from its entry;
|
||||
`attachment.withdrawn` (`attachment_id`, `reason`) retires an unlinked
|
||||
receipt or a document (`withdrawn_at`, `withdrawn_reason` columns, schema
|
||||
v5). Linking requires an unlinked, non-withdrawn attachment, so evidence
|
||||
only leaves an entry through a recorded unlink. Blobs are untouched: a
|
||||
withdrawn file is still hash-verified and readable, and the journal shows
|
||||
when it was filed, linked, unlinked, and withdrawn, and why.
|
||||
- The store is policy-free: it records attachments faithfully. Whether a
|
||||
posting *requires* a receipt is decided by the MCP layer's receipt policy
|
||||
(`docs/mcp-interface.md`), which reads the `attachments` table.
|
||||
|
||||
@@ -52,6 +52,12 @@ requirement conflict, the requirement wins.
|
||||
|
||||
## Receipt policy (MCP phase)
|
||||
|
||||
- **R-13a — Receipt corrections.** Posted entries are immutable (R-3); their
|
||||
evidence is not frozen but is append-only. A receipt is replaced by
|
||||
attaching the new one and unlinking the old with a reason; unwanted
|
||||
receipts and documents are withdrawn with a reason. Unlinking may never
|
||||
leave an entry the policy (R-13) requires a receipt for with none. Every
|
||||
step is a journal event; no blob is deleted.
|
||||
- **R-13 — Receipt enforcement.** The MCP layer (not the engine — the engine
|
||||
stays policy-free) enforces a configurable receipt policy: postings at or
|
||||
above a threshold (`require_receipts_above`, default `"25.00"` in base
|
||||
|
||||
Reference in new issue
Block a user