Metadata-Version: 2.4
Name: pacioli
Version: 0.30.2
Summary: Governed agent broker for ERPNext (MCP + A2A doors, one spine) — no debit without a credit: PLAN, CONSENT, PROVE, UNDO over the books, on the Pacioli Guard credential floor
Author: John Broadway
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/john-broadway/pacioli
Project-URL: Source, https://github.com/john-broadway/pacioli
Keywords: mcp,erpnext,frappe,ai-agents,governance,audit,accounting
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Office/Business :: Financial :: Accounting
Classifier: Topic :: Security
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: server
Requires-Dist: mcp>=1.0; extra == "server"
Provides-Extra: a2a
Requires-Dist: a2a-sdk[signing]>=1.1; extra == "a2a"
Requires-Dist: uvicorn>=0.30; extra == "a2a"
Requires-Dist: cryptography>=42; extra == "a2a"
Dynamic: license-file

<!-- mcp-name: io.github.john-broadway/pacioli -->
<img src="../assets/marks/device.svg" width="72" align="right" alt="The printer's device"/>

*Tractatus de Instrumentis — the broker.* ❧ *Nulla in libro sine contraria — no debit without a credit.*

# Pacioli — the ERPNext agent broker you can hand the books, governed (MCP · A2A)

**Status: four doctypes — Sales Invoice, Purchase Invoice, Payment Entry, Journal Entry — are
live-proven end-to-end on a real ERPNext v16 bench: governed submit / cancel / amend, cascade
cancel, the workflow-SoD gate, and governed Payment Reconciliation (PHASE X, 2026-07-09), all as a
scoped non-Administrator seat under [`pacioli_guard`](../guard/README.md). A certified
least-privilege reference seat (Accounts User + a custom "Pacioli Seat" role) is proven live on the
Sales Invoice submit/cancel vertical and certified by `doctor` across the broker's whole read
surface (PHASE X, T-P1/T-P2); the other three doctypes' full write path was live-proven under the
broader scoped seat, and rides native `Accounts User` grants under the tight seat by source-derived
recipe, not yet re-run there.** The current version lives in `pyproject.toml`/`CHANGELOG.md` — this README deliberately
does not restate it (restated versions rot). The proof trail is `../SCOPED-TOKEN-PROOF.md`; the
early gates in brief — Gate 6:
`plan_submit` returned the real projected GL (Cost of Goods Sold / Creditors), a minted marker took
a real PI docstatus 0→1, governed cancel took it 1→2 with the bench showing the equal-and-opposite
reversing GL, the receipt chain verified with every receipt self-describing its doctype, and the
breadth security headline held live — a Purchase Invoice plan is refused by the Sales Invoice submit
tool and vice versa (`check_doctype`). Every 0.4.0 leg has run against a real ERPNext bench: the
governed **submit** and **cancel** verticals, the **amend** re-draft (the full submit → cancel →
amend → resubmit arc on one 8-receipt ledger), and the **off-box PROVE anchor** (pin, green check,
and a truncated ledger caught). The governed **submit** vertical is
**live-proven end-to-end against a real ERPNext bench** (2026-07-02): `plan_submit` returned the
real projected GL from ERPNext's native ledger preview, a human-minted marker gated the write,
`submit` took a real draft Sales Invoice docstatus 0 → 1 as a scoped non-Administrator user under
[`pacioli_guard`](../guard/README.md), the spent plan was refused on replay, and the sealed
receipt chain verified with the intent+outcome pair. The governed **cancel** (UNDO) is live-proven
the same way, against the same invoice: the plan showed the exact GL rows to unwind, a cancel
marker presented to `submit` was refused (consent does not transfer between operations), cancel
took docstatus 1 → 2, and the bench DB shows the literal equal-and-opposite reversing GL rows.

This build also adds CONSENT's second gate — riding a company's own ERPNext **Workflow** as its
separation-of-duties law (`workflow_status` / `request_workflow_transition` / the outright
submit-cancel refusal when a workflow governs the op), now **live-proven against a real ERPNext v16
bench** (Gate 5): a valid minted marker was still refused at the workflow stage, a non-approving
transition was performed by the broker while the approving one was refused broker-side, frappe's
own self-approval block fired on a direct `apply_workflow` (417), and a cancel the workflow does
not govern was correctly *not* blocked. Still an opt-in gate that governs the broker's own path —
frappe does not enforce Workflow on a direct `docstatus` change (see Honest scope and the CONSENT
row below).

**Since Gate 6, three more increments landed and were taken to a live v16 bench (0.8.1, Gates 8/9
+ PHASE J, 2026-07-06 — record in `SCOPED-TOKEN-PROOF.md`).** **Payment Entry** breadth is now
**live-proven** end-to-end (plan → submit 0→1 with the *referenced invoice's* outstanding moving,
cross-doctype refusal both ways, cancel 1→2, prove chain). The **frozen-books fix** (0.8.0,
**BREAKING** — a new `Company` read grant, see Preconditions; it was silently reading a field
ERPNext v16 no longer has, for *every* doctype already shipped) is proven via `pacioli doctor`
FAIL→PASS. **Cascade cancel** (0.7.0): a discovery-shape bug was found and fixed live (0.8.1); its
mechanism — ordered discovery, fail-stop, marker-once, the happy path — is proven.
**Journal Entry's submit/cancel (0.9.0): BLOCKED → built via the guard-scoped body-doctype path →
LIVE-PROVEN (2026-07-07, Gate 10 close-out — `SCOPED-TOKEN-PROOF.md` PHASE M).** Its **governance legs are live-proven** (the
Exchange-Gain-Or-Loss refusal and the independent debit==credit check both fired live, PHASE L).
Its submit/cancel *were* blocked on a real ERPNext incompatibility — `JournalEntry` overrides
`submit()`/`cancel()` without frappe's whitelist, so the broker's originally-only submit shape
(`run_method` on the URL-path doc-method surface) is rejected — and now ride `frappe.client.submit`
/`.cancel` (the doctype-in-body RPC surface) instead, safe to enable **only** because
`pacioli_guard` 0.5.0 closed its matching residual (`scope.body_scoped_target` parses that body and
enforces the credential's per-doctype grant on it, exactly as strictly as the `run_method` shape).
SI/PI/PE are unchanged (regression re-proven live in the same window). **Live-proven 2026-07-07:**
JE submit 0→1 landed verbatim as `frappe.client.submit` in the bench request log, cancel 1→2, and a
raw body-doctype submit naming a never-granted doctype was 403'd before frappe dispatched (PHASE M).

Pacioli is a standalone, pip-installable **MCP server** that gives an AI agent a governed way to
touch ERPNext. Slice-one governs exactly one write — **submitting a Sales Invoice** — through the
full shape PLAN → CONSENT → execute → PROVE, plus a minimal read tier. Everything else is
deny-by-default: there is no generic `delete`, no raw REST passthrough, no `confirm`-flag the
agent can set on its own call. It is not a bench app — that's [`pacioli_guard`](../guard/README.md), the
credential-scope floor this broker sits on and binds itself to.

## The trust spine in slice-one

| Pillar | What ships in slice-one | Deferred |
|---|---|---|
| **PLAN** | Load the draft, run ERPNext's native `show_accounting_ledger_preview`, return the projected GL impact + risk flags, record the plan bound to the doc's `modified` version (so a stale plan can't be replayed after the doc changes underneath it). | Linked-master drift (credit limit, tax template, FX) is not covered — documented, not silently covered. |
| **CONSENT** | Two gates now. The **marker**: a single-use, out-of-band, human-minted grant the agent cannot derive or self-issue — state machine `live → reserved → consumed` (or back to `live` on a failed submit), CAS-claimed before execute. **Workflow-SoD** (`pacioli/workflow.py`, **live-proven against a real ERPNext v16 bench**, Gate 5): when a company has configured an active ERPNext Workflow on the doctype, `submit`/`cancel` refuse outright whenever that workflow governs the op — naming the workflow and the approving role — and the broker may still perform non-approving transitions itself (`request_workflow_transition`). No workflow configured = unchanged, marker-governed exactly as before this build. | Bench-side (guard-side) enforcement of Workflow against every calling path, not just the broker's — frappe does not enforce Workflow on a direct `docstatus` change, so this gate governs only the agent's own path through the broker. |
| **PROVE** | An append-only, hash-chained receipt of every mutation: an **intent** receipt is written before the irreversible submit, then a **committed** or **failed** outcome after. Plus the **off-box head anchor**: `pacioli anchor write` emits a pin (target, head hmac, receipt count, timestamp — and, since 0.21.0/0.26.0, the seal and close-record heads+counts riding the same record) for the operator to carry off this host; `pacioli anchor check` verifies the live chain against a recorded pin, deny-biased (a count that went down, or a head that moved, is tampering — exit 1). With a **disciplined off-box pin**, PROVE upgrades from an on-box hash chain to **tamper-evident against host-level truncation or rewrite of the chain since the last pin** — exactly that, no more. | Receipts appended **after the last pin** are unprotected until the next pin, and the seal key is still on-box — a key-holder can forge post-pin receipts (pre-pin history stays fixed by the pinned hmac even against a key-holder). The tool cannot make the pin off-box for you: a copy on this host proves nothing; carrying it off (another machine, a git remote, paper) and rotating it is operator discipline, not code. |
| **DIAGNOSE** | A minimal read tier: get / list Sales Invoice, permission-scoped under the caller's own ERPNext role. | A broader read tier, health/evidence tools. |
| **UNDO** | **Governed cancel** (`plan_cancel` → human marker → `cancel_sales_invoice`, docstatus 1 → 2, **live-proven**): the plan shows the posting's live GL rows (what the cancel unwinds), the **linked-submitted-documents blast radius refuses** a non-leaf cancel (an unreadable graph refuses too — never reads as empty), the same closed-books check blocks unwinding into a locked period, and a cancel marker **never authorizes a submit** (nor vice versa — plans are op-bound). ERPNext's own cancel-blocks are honored, never bypassed. **The settling-PE disclosure (F-R1, every supported doctype):** the plan also names, PRE-consent, every settling voucher (a Payment Entry, most commonly) a cancel would touch — one flag per voucher, either "cancelling will SILENTLY UNLINK `<voucher_type>` `<voucher_no>`'s allocation of `<amount>`…" (the unlink setting is ON) or "ERPNext will REFUSE this cancel (LinkExistsError)…" (it's OFF) — closing the gap where ERPNext's own blast-radius check structurally cannot see a settling Payment Entry (it's `auto_cancel_exempted`); an unreadable settling-reference or unlink-setting read refuses the whole plan, deny-biased. **Amend** (`amend_sales_invoice`, **live-proven** — the full submit → cancel → amend → resubmit arc, 8 receipts on one ledger, proof record PHASE F): the corrected re-draft, a new DRAFT copied from the cancelled document with `amended_from` set. It takes **no marker** — it creates a reversible draft (nothing posts; deleting the draft undoes it), and demanding consent for a reversible act would dilute what the marker means; the irreversible step stays `submit`, behind its own plan + marker. It **does** write the intent+outcome receipt pair (op `amend`, transition `2->0(draft)`) so the book shows the full arc cancel → amend → submit. Under an active Workflow the draft is **seated at the workflow's initial state** (frappe's own `states[0]` convention, written to the workflow's configured state field, disclosed as `workflow_seat` in result and receipts, and `confirmed` against the bench's answer, never just the request) — found by the first dogfood drive: an unseated amendment has no legal transition and is stuck. The seat is **live-proven** (2026-07-17 lab pin, `docs/plans/2026-07-17-amend-seat-pin.md`): amend under a live workflow seated the draft on the real bench, and the non-approving transition ran from it end-to-end. Gated: refuses an uncancelled source, a source that already has an amendment (any docstatus — named in the refusal), a wrong-books company, ambiguous/malformed/unseatable workflow configuration, and a state field that would re-enter the amend strip-list's protected keys (deny-biased, before the intent receipt — a refusal never leaves an orphan). **Cascade cancel** (`plan_cascade_cancel` → human marker → `cascade_cancel`, **live-proven** — discovery/fail-stop/happy path PHASE J, a real JE-dependent graph cancelled 2/2 in Gate 10 M-12): governs the cancel of a document AND its full submitted-dependent graph in one consent. The plan enumerates the topologically ordered graph (dependents first, target last, any doctype, each node labeled modeled/generic) and discloses per-node risk the same way the single-op plan would (`risk_flags`, each flag docname-prefixed: the Journal-Entry EG auto-cancel note, the unlink setting, the physical-stock reversal, a Payment Entry's settled references), and one marker authorizes exactly that frozen set. Non-atomic by nature (each cancel commits individually): preflight checks freshness + period-locks on every node before any cancel, then executes in order, **confirms each node's transition against the document's real docstatus** (a response that doesn't show docstatus 2 — a queued write, a failed readback — records `unconfirmed`, never `committed`: the E1 rule, per node), and **fail-stops** on the first failure or unconfirmed node — the result names exactly what was cancelled and where it stopped; the marker is spent iff at least one document was cancelled **or the stopping node came back unconfirmed** (an act may already be in motion server-side — one grant must never initiate two acts). Bounded by `PACIOLI_CASCADE_MAX` (default 25). | The 0.9.3 per-node confirm + per-node cascade disclosures are code-proven, bench proof staged (envelope E3). **0.10.4 closed the transport-taxonomy residual** (single-op and cascade alike; code-closed and unit-proven — bench pins T1–T5 staged for the next window): an exception from the *mutating call itself* is now classified — an **answered refusal** (the bench definitely saw and refused the call: an int HTTP status whose JSON body carries frappe's OWN error envelope, `exc_type`/`_server_messages`, or a pre-processing rejection, 429/413) still releases the marker exactly as before; everything else — a raw connection failure, a proxy-shaped response (HTML **or generic JSON** — proxies speak JSON too, and `{"error": "Bad Gateway"}` is not frappe), any unconverted exception — is **no answer**, and the marker is now **spent, never released**, resolved by a governed readback of the document's real docstatus (`confirmed_via: "post_failure_readback"` on a match, `"unconfirmed"` + `readback_error` on a mismatch or a failed readback; the durable receipt always carries the original error too). Deny-biased by construction: this closure only ever moves release→spend, never the reverse. Honest residual: the "answered ⇒ rolled back" premise is source-verified for frappe core + erpnext mainline flows; a CUSTOM doctype hook calling `frappe.db.commit()` mid-flow before a later failure would break it — outside the broker's reach, recorded. |

**The closed books.** Closing the ledger is Pacioli's own operation — rule off the book, carry the
balances forward. Where ERPNext has closed the books — a closed Accounting Period, a
Period-Closing-Voucher boundary, a company's `accounts_frozen_till_date` — the broker refuses and
says so. It never sets `adv_adj=True` and never passes a future `posting_date` to slip an entry
past the ruling-off. Refusing and escalating instead of forcing past a block is the whole idea:
the machine does not write in a closed book by its own hand. **The Accounting Period leg of this
check is doctype- and date-range-aware (F-S1, 0.10.0)** — it matches ERPNext's own rule (a
posting inside a *specific* enabled period that closes *that* doctype) rather than refusing every
doctype past the latest period's end date; no new credential grant is needed (the extra
full-document read classifies to the same `Accounting Period` read target the list read already
required). **The period LIST itself filters only company + date range, never `disabled` (F-C1,
0.10.2)** — `disabled` is v16-only on `Accounting Period` and filtering on it breaks a v15 bench
outright (an unknown-column error, not "no match"); `disabled` is read instead off the same
full-document GET that already reads `closed_documents`, and its absence there (the v15 shape) is
the correct reading of "enabled," not a guess.

**CONTAIN** has two layers now. The credential floor: [`pacioli_guard`](../guard/README.md) ships
the per-credential **kill switch** (untick one field — every request from that credential denies
on its next call, no restart) and a **per-minute rate limit** (best-effort velocity damping,
honestly not tamper-evident accounting), each denial leaving an audit row. And the broker's own
**seal** — a fail-closed, reversible CONTAIN the broker applies to itself, detailed next.

### The seal (CONTAIN)

The seal is the broker's own confession made structural: the moment it cannot vouch for its own
books — a CONTAIN-level gap, or an operator's word — it stops writing and says so. It is
broker-local, reversible, and every seal/unseal is itself a recorded, HMAC'd entry (the same store
key that already seals every PROVE receipt, under a domain-separated prefix so a receipt HMAC can
never be replayed as a seal-event HMAC or vice versa).

**What seals it.** An operator's own `pacioli seal --reason <why> [--target NAME]` — direct,
explicit, any time. Or `close --respond --envelope CLASS=LEVEL` (repeatable), when the operator
escalates a finding class to `contain` (e.g. `--envelope orphan=contain`) and the resulting
response's `seal_required` comes back true: `cmd_close` seals the store itself
(`source="response"`), names the class(es) it reached CONTAIN via and the period in the seal
reason (truthful phrasing — see the `chain_broken` paragraph below for why this never says
"escalated"), and shows the resulting seal block in the render (or `--json`). Or — since broker
0.22.0, see `chain_broken` below — a `close --respond` against a broken receipt chain, with no
envelope at all.

**Invariant 1, revised 2026-07-15 (kept honest, not silently rewritten — the original text is on
record in `response.py`'s module docstring, dated), restated same-day to the real axis by the
redteam fix wave.** Through 0.21.0 this line read: "CONTAIN is never a default — no finding's
deny-biased floor is `contain` — so the seal only ever lands because an operator either sealed
directly or explicitly wrote `...=contain` into an envelope." The first 0.22.0 revision framed the
exception as "own ledger vs. another party's movement" — that axis was wrong: `orphan`/
`unconfirmed` are ALSO Pacioli's own writes and correctly stay at `alert`, so "own" alone cannot be
what earns CONTAIN. **The honest replacement, and the real categorical line: CONTAIN is never a
default for a working ledger honestly recording a known uncertainty.** `chain_broken` is CONTAIN
because the attestation apparatus is **provably broken** — the HMAC chain cannot verify itself, so
no record in it can be trusted. Every other finding class, including Pacioli's own `orphan`/
`unconfirmed`/`second_generation`, `blind_read` (an external read going dark), and `ungoverned`
(another party's legitimate movement), stays at `alert` or below unless the operator explicitly
escalates it — because in every one of those cases the ledger **verifiably records a known
uncertainty**: the apparatus works and is honestly confessing an open item, which is a
fundamentally different thing from the apparatus itself being unable to vouch for anything. See
`chain_broken` below for exactly what fires it and the false-seal guard that keeps an ordinary
clean close from sealing itself. No other class may ever gain a `contain` floor.

**`chain_broken` — the sole default-CONTAIN finding class (broker 0.22.0, narrowed same-day by the
redteam fix wave, D1 — John ruled).** `close --respond` emits a `chain_broken` finding, floor
`contain`, when the Statement's own `chain` block shows `verified is False` (the keyed PROVE-chain
verify actively failed — tampered/corrupt receipts) — **and that signal alone.** The original
0.22.0 build also fired on `anchor_matches is False` (an off-box anchor supplied and the live head
disagreeing with it); that branch was DROPPED the same day the model-fidelity redteam found it
load-bearing-broken: `close.py`'s `anchor_matches` is a NAIVE `head == anchor` equality that
false-seals a legitimately-grown chain fed a stale `--expected-head` — a default-CONTAIN auto-seal
firing on a perfectly untampered ledger. The real, count-aware receipt-rollback detection already
lives in `pacioli anchor check` / `seal-status --anchor` (the 0.21.0 seal-anchor tooling), which
correctly reads a grown chain as clean; `anchor_matches` keeps feeding `build_statement`'s own
`balanced` check and the human render (unchanged there) — it just no longer drives this
default-CONTAIN floor. **The false-seal guard — a pinless clean close never seals:** a clean verify
(`verified is True`) emits nothing, and neither does `verified is None` (verify not run at all — a
synthetic/non-`cmd_close` statement). Only a genuine, identity-checked `False` fires it — never
falsiness, so `None` (not run) can never be mistaken for a failure. The envelope can escalate
`chain_broken` (already at its `contain` ceiling, so a no-op) but can never silence it below
`contain` — a broken chain cannot be configured away. This makes `seal_required` REACHABLE on a
compromised chain; it does not change how the seal is performed — that is still the `cmd_close`
auto-seal path described above, unmodified since 0.20.0.

**What a sealed broker refuses.** Every governed write: every `plan_*` tool, every
`submit_*`/`cancel_*`/`amend_*`, `cascade_cancel`, `reconcile`, `request_workflow_transition` — all
refused at the dispatch choke point before the handler is even invoked, so nothing is claimed and
no marker is spent. `pacioli mint` also refuses on a sealed store (a keyless, defense-in-depth
pre-check; the keyed dispatch gate is the authoritative one). A marker minted or reserved before
the seal landed is untouched by the refusal — it survives, and commits normally once the seal
clears.

**What it still serves.** Every read: `get_*`/`list_*`, `workflow_status`, `prove_verify`,
`prove_orphans`, `doctor`, plain `close` and `close --reconcile`, and `pacioli verify` / `orphans`
/ `anchor` / `seal-status` all work while sealed — the confession has to stay legible, or the seal
would hide the very books that explain it. `pacioli seal-status [--target NAME] [--json]` renders
the current state and a tail of the seal history any time, exit 2 while sealed, exit 0 while
unsealed (scriptable) — it never crashes on a fail-closed state, it renders the cause (no seal
history, a rollback gap, or an unverifiable latest event all read as sealed). **Since 0.21.0,
`seal-status --anchor <pinfile>`** threads a recorded off-box pin's `seal_head`/`seal_count` into
that same read, so a tail-rollback against the pin renders SEALED here too — the everyday status
command, not only a dedicated `anchor check` run. See the anchor paragraph below for what this
does and does not prove.

**How to clear it.** `pacioli unseal --reason <why> [--target NAME]` — appends an `unseal` event
and resumes governed writes; exit 0 once the state reads unsealed. Unsealing is never automatic —
only an operator's own command clears it, never a side effect of any read or write.

The floor-level act — revoking the seat credential in `pacioli_guard` — is the operator's hand,
never the broker's. The broker writes only into its own store; it never writes into `guard`, never
touches a credential. If a seal alone is not enough — the credential itself needs pulling — that
stays a human runbook step at the guard layer, not something this seal automates.

**Honest ceiling.** The seal is a fail-closed FORWARD control: a sealed store refuses every
governed write, and interior-row deletion (a `seq` gap) and a keyless in-place edit of ANY row's
content — not only the latest's (closed 2026-07-15, security redteam F1(a): every row's HMAC is
now recomputed and checked on a keyed read, not just the latest's — before this, a keyless
interior-row edit read clean once that row was no longer "latest") — and forging a history with no
key at all ALL fail closed. On its own — no pin supplied — it is **NOT** rollback-resistant against
a keyless attacker with DB-file write access who deletes the NEWEST `seal_events` row(s) — tail
truncation, or wiping the table plus `sqlite_sequence` and letting genesis re-seed: seq-contiguity
cannot see a missing tail, so a genuine, untouched earlier row can become "latest" and read as a
legitimate — possibly unsealed — state (security redteam 2026-07-14, reproduced firsthand: seal →
`sealed=True`; delete the newest row → `sealed=False`). This is the same on-box limit the receipt
chain has always disclosed — closed there, for receipts, by the off-box `pacioli anchor` pin.

**Since 0.21.0, that same rollback is DETECTABLE against an off-box seal anchor — audit-time, not
real-time.** One pin now covers both chains (all THREE since 0.26.0 — the close-record history
rides the same pin, see the close-record section below): `pacioli anchor write` also reads the seal
table's own head hmac + row count and pins them alongside the receipt head, and `pacioli anchor check`
(and, since 0.21.0, `seal-status --anchor <pinfile>` too) verifies the live seal history against
that pin — a live history shorter than the pin, or the row actually AT the pinned position
disagreeing with the pin, fails loudly, naming the tail-truncation or divergence. This changes
nothing about what happens *on-box*: nothing here blocks a write, the pin does not run on every
`seal`/`unseal` call, and a rollback followed by a re-forward before the next check is invisible —
the detection window is the operator's own check cadence, exactly the disclosed limit the
receipt-side anchor has always carried. **Re-pin after every `seal`/`unseal`** — this is the
load-bearing operator discipline, not optional hardening: a seal or unseal taken after the last
`anchor write` is not covered by that pin, so a rollback back to the pre-seal/pre-unseal state is
silently indistinguishable, at the next check, from a normal unseal/re-seal. `pacioli seal` and
`pacioli unseal` print a reminder after every append; `pacioli anchor write` prints the same
reminder after every pin.

**This is NOT the same guarantee the receipt anchor carries, and it never claims to be (corrected
2026-07-15 — the prior text here overclaimed this).** The receipt chain is prefix-chained
(`prev_hash`), so its pin fixes the WHOLE pre-pin history — even against a key holder, editing
anything before the pin forces every hmac after it to change too. `seal_events` has no such
chaining: each row's HMAC covers only that row's own content, independent of every other row. So
the seal pin fixes only the ONE row it names by position (plus the row count) — not the rest of
the pre-pin history. A key-HOLDING attacker can still edit any OTHER row — including one earlier
than the pinned position — and recompute a fresh, self-consistent HMAC for just that row; neither
the per-row keyed check above nor the pin (it only compares the one row at the pinned count)
catches it. Forging a fully self-consistent seal history *after* the pinned position is likewise
still possible with the key — key possession is authorship, on-box, until the key itself is
anchored off-box. Both of these make the seal pin's ceiling **narrower** than the receipt chain's,
not identical to it — the receipt side's prefix-chaining has no equivalent here.

**Closed in 0.22.0: a broken PROVE chain now reaches the seal.** Through 0.21.0, `close --respond`'s
response never inspected the statement's own `chain.verified` (nor, for one same-day-reverted
build, `chain.anchor_matches`) — a broken receipt chain produced no `response.py` finding, so
`seal_required` couldn't fire on it and no `--envelope` could reach it; plain `close`/
`close --respond` still exited 1 on an unverified chain via the pre-existing `balanced` check, so
nothing silently passed, but the auto-seal itself had a blind spot for the most literal instance of
"the books can't even attest to themselves." As of 0.22.0, `close --respond` emits a `chain_broken`
finding at floor `contain` on exactly the verify-failure case (the anchor-mismatch branch was
built, then dropped the same day — see the `chain_broken` paragraph above) — see "`chain_broken` —
the sole default-CONTAIN finding class" above for the trigger, the false-seal guard, and why this is
a principled exception to invariant 1 rather than a loosening of it.

### The close-record + attestation gate (the period loop, broker 0.23.0)

Response level 2 in Half 3's spectrum — the attestation-gate — sat computed and inert since Slice
1: `response.py` set `gate_required` and nothing acted on it, exactly the state `seal_required` was
in before the seal above gave it teeth. This gives the gate its own state: a minimal, append-only,
HMAC'd `close_records` table (domain-separated the same way as `seal_events`, every row's HMAC
recomputed and checked on a keyed read, not only the latest's) recording every `close --advance`
and every `pacioli attest`.

**Scope, stated plainly — the gate stops exactly one thing.** Writing the NEXT close record
(advancing the period cursor). It never touches dispatch, `mint`, any read, or any governed write
(`plan_*`, `submit_*`, `cancel_*`, `amend_*`, `cascade_cancel`, `reconcile`,
`request_workflow_transition`) — that is the seal's job, described above. One mechanism per
confession: the seal stops the pen, the gate stops the page-turn.

**Plain `close` and `close --respond` are unchanged.** Neither writes to `close_records`, neither
renders a cursor/gate line — byte-identical to every close before this table existed. Advancing the
period loop is opt-in: `pacioli close ... --respond --advance`. `--advance` without `--respond` is
a usage error (exit 2) — there is no response to advance past if `--respond` didn't run.

**What `--advance` does.** Everything `--respond` does, plus: after the render, it writes a `close`
row — period bounds, the chain head at close time, and whether this close's own `gate_required` was
true (`gapped`). When `--since` is omitted it defaults from the verified cursor (the previous
close's `period_until`) rather than genesis, so successive closes cover only new activity — unless
no close has ever been recorded, in which case genesis stands; an explicit `--since` is never
overridden. It refuses the WRITE (exit 1; the read/render above it never refuses — reads are never
gated) when the gate is already LATCHED: a prior close closed over a gap with no later attestation,
or the close-record history itself fails integrity verification (a `seq` gap, an unverifiable
HMAC). A close that itself closes over a gap is still recorded — that recording is what latches the
NEXT advance ("the close that finds the gap records itself").

**The ceremony.** `pacioli attest --target NAME --reason <why>` is an operator act, mirroring
`unseal`'s shape exactly: always `source="operator"`, `--reason` required, appended, never edited.
It clears a gapped close awaiting attestation and refuses (exit 2) when nothing is currently
pending — including when the gate is latched for an integrity reason instead, since attesting
cannot repair a corrupt history, only confess to a real, reviewed gap.

**`pacioli close-status [--target NAME] [--json]`** — read-only, never gated: renders the cursor
(in words — "no period ever closed" / "latest close is open-ended" / an integrity failure, never a
bare `None` left for the reader to guess at), the gate state (OPEN/LATCHED), and a history tail.
Exit 2 while LATCHED (any cause), 0 while open — scriptable, mirrors `seal-status`.

**Deny-biased derivation, with one deliberate divergence from the seal.** Zero rows reads OPEN, not
sealed: no close has ever happened is the honest genesis state for a *cursor* ("no period has ever
closed"), not a fail-closed case, so the first `--advance` against an empty table is legitimate.
Past that, the derivation is exactly as conservative as the seal's: a `seq` gap, an unverifiable
HMAC, or a keyless open all latch the gate.

**The off-box pin covers this table too (0.26.0 — the count-anchor slice).** The derivation above
cannot see a TAIL-ROW DELETION: delete the newest close row(s) and the survivors stay contiguous
and verifying, the gate reads clean, and the cursor has silently rolled back to an earlier
period's `until` — the same disclosed limit the seal table had, closed the same way. `pacioli
anchor write` pins the close table's own head hmac + row count alongside the receipt and seal
pairs (anchor format v3 — one pin, all three chains; an empty close table pins honestly as
"nothing closed yet", count 0), and `pacioli anchor check` replays them: a live close history
shorter than the pin, or the row at the pinned position disagreeing, fails loudly. A pre-v3 pin
still checks everything it covers and WARNS that the close chain is not covered — never silently
"fine". Same honest ceiling as the seal's pin (per-row HMAC, not prefix-chained: the pin fixes the
one row it names plus the count, never the whole history against a key holder), same operator
discipline: **re-pin after every `close --advance`/`attest`** — a row appended after the last pin
is not covered until the next one.

**Concurrency, honestly (compare-and-append).** `record_close` checks the caller's planned
last-close-seq against the store's current one inside its own transaction, after the latch check —
a concurrent `--advance` landing first refuses the loser's write with a stale-cursor message (exit
1, "re-run this exact command" — the period math must be recomputed against the moved cursor),
never a silent double-write of overlapping periods.

**Composition-not-coupling.** The close-record lives only in the broker's own store. No new
`pacioli_guard` grant, no new ERPNext read, no write outside this one table.

```
$ pacioli close --target prod --respond --advance
  advance:     cursor recorded for period genesis..now (seq 1)
  gate:        OPEN — the next advance is not blocked

$ pacioli close --target prod --respond --advance --envelope orphan=attestation_gate --until 2026-08-01T00:00:00Z
  advance:     cursor recorded for period ...2026-08-01T00:00:00Z (seq 2)
  gate:        LATCHED — this close closed over a gap (gate_required); the NEXT advance is
               refused until `pacioli attest --target prod --reason <why>`

$ pacioli close --target prod --respond --advance
  advance:     REFUSED — close at seq 2 closed over a gap (gate_required) and has not been
               attested (stuck period ended 2026-08-01T00:00:00Z)
  fix:         pacioli attest --target prod --reason <why> — that clears the gap before the
               cursor can advance

$ pacioli attest --target prod --reason "reviewed the gap — a known timing artifact"
gate:     OPEN — the next `close --advance` is not blocked

$ pacioli close --target prod --respond --advance
  advance:     cursor recorded for period 2026-08-01T00:00:00Z..now (seq 4)
  gate:        OPEN — the next advance is not blocked
```

Design: `docs/plans/2026-07-15-close-half3-close-record.md`.

## Install

```bash
pip install pacioli
```

The CLI (`pacioli mint` / `verify` / `orphans` / `anchor` / `doctor`) is stdlib-only. The agent-facing MCP server needs
one extra dependency:

```bash
pip install 'pacioli[server]'
```

## Preconditions — read before you point this at a real bench

**1. The broker's own ERPNext credential must be `pacioli_guard`-scoped, and least-privilege.** The
broker authenticates to ERPNext as a scoped user that holds **neither Administrator nor System
Manager**. The literal Administrator (and bench/DB access) bypasses every ERPNext permission check
outright; System Manager holds no runtime bypass but can administer the governance *away* over the
REST surface — write Custom DocPerm rows (grant itself any permission), mint API keys, and (with
server scripts enabled) run arbitrary server-side code via the System Console — so running as
either voids the whole model. `pacioli doctor`'s roles probe refuses such a seat at readiness time.
Beyond that, [`pacioli_guard`](../guard/README.md) must scope the broker's own credential to exactly
the calls it needs:

- read access to the Sales Invoice DocType (and, for UNDO's reversal preview, **GL Entry**, for
  the frozen-books period lock, **Company**, and for the settling-PE cancel disclosure,
  **Payment Ledger Entry** — see the BREAKING notes below),
- the `show_accounting_ledger_preview` method (PLAN's dry-run),
- the `frappe.desk.form.linked_with.get_submitted_linked_docs` method (UNDO's blast-radius read),
- the submit and cancel methods on Sales Invoice (`run_method=submit` / `run_method=cancel`),
- the `User.get_roles` method (doctor's least-privilege self-check — see the BREAKING note below).

> **Using the Purchase Invoice tools too?** They need the same shape of grant, on Purchase
> Invoice: read access to the Purchase Invoice DocType, and its submit/cancel methods. See
> "Supported doctypes" below — live-proven on a real v16 bench (Gate 6; regression re-proven
> 2026-07-07, PHASE M).

> **Using the Payment Entry tools too?** Same shape of grant, on Payment Entry: read access, and
> its submit/cancel methods. Payment Entry's own `references` rows can point at Sales Order,
> Purchase Order, Sales Invoice, Purchase Invoice, Journal Entry, or Dunning (Customer payments)
> or Purchase Order/Purchase Invoice/Journal Entry (Supplier payments) — a credential that also
> needs to *resolve* what a Payment Entry settles (not just plan/submit/cancel the payment itself)
> needs read access on whichever of those doctypes are actually in use at your site; this broker
> does not require it for the tools it ships, but names it here so an operator scoping tightly
> knows the wider surface exists. Knowledge-pinned, not live-verified, same as everything else in
> this box until a bench falsifies it.

> **Using the Journal Entry tools too?** A DIFFERENT shape of grant from SI/PI/PE (0.9.0) — Journal
> Entry submits/cancels via `frappe.client.submit`/`.cancel`, not `run_method`. The credential needs
> read access on Journal Entry, PLUS the **method names** `frappe.client.submit`/`.cancel`
> allowlisted (`pacioli_guard`'s `methods` grant) AND the **per-doctype pattern**
> `Journal Entry.submit`/`Journal Entry.cancel` (guard 0.5.0's `body_scoped_target` rewrites the
> generic RPC name to this per-doctype target before enforcing — holding only the bare
> `frappe.client.submit`/`.cancel` method name is no longer enough to submit/cancel ANY doctype
> through it, by design; see `pacioli_guard/CHANGELOG.md` 0.5.0). No NEW Accounts Settings grant is
> needed beyond what every other doctype's governed write already requires (`get_period_locks`
> reads Accounts Settings on every submit/cancel already) — `plan_cancel`'s Journal-Entry-specific
> disclosure (`unlink_payment_on_cancellation_of_invoice`) rides the same DocType-level grant.
> This grant shape is **live-proven** — Journal Entry submit/cancel ran end-to-end (0→1→2) through
> exactly this body-doctype grant at the Gate 10 close-out (2026-07-07, `SCOPED-TOKEN-PROOF.md`
> PHASE M).

> **⚠️ UPGRADE / BREAKING — the Workflow-SoD gate needs a new read grant.** Every governed
> `submit` and `cancel` now reads the `Workflow` DocType first (the gate source), and an
> unreadable gate source **refuses** — deny-biased, never read as "no workflow configured", the
> same house rule as the period-lock reads. `Workflow` is **System Manager-read-only by frappe
> default**, so **an existing broker credential scoped before this build will have ALL governed
> writes denied until the grant is added** — whether or not any workflow is configured. The
> migration step: Role Permission Manager → `Workflow` → custom **read** permission for the
> broker's role. `pacioli doctor` now probes exactly this and prints the remedy (a 403 on the
> workflow read is a FAIL there — deliberately opposite to the method probe's 403-is-PASS rule,
> because the gate requires readability). `request_workflow_transition` additionally needs the
> whitelisted `frappe.model.workflow.apply_workflow` method reachable.

> **⚠️ UPGRADE / BREAKING — the frozen-books lock needs a new `Company` read grant.**
> `get_period_locks` now reads `Company.accounts_frozen_till_date` as the primary frozen-till-date
> source (ERPNext v16 migrated this off `Accounts Settings.acc_frozen_upto` — the field the broker
> read before this fix, which is silently absent on a v16 bench). An unreadable `Company` doc
> **refuses** — deny-biased, the same house rule as every other lock source — so **an existing
> broker credential scoped before this fix will have EVERY plan/submit/cancel denied until the
> grant is added**. The migration step: Role Permission Manager → `Company` → read permission for
> the broker's role. `pacioli doctor` now probes exactly this and prints the remedy (a 403 on the
> Company read is a FAIL there, the same deliberate inversion as the Workflow-read probe above).

> **⚠️ UPGRADE / BREAKING — the settling-PE cancel disclosure (F-R1) needs a new `Payment Ledger
> Entry` read grant.** Every doctype's `plan_cancel`/`plan_cascade_cancel` now reads Payment Ledger
> Entry (`get_settling_references`) to disclose what a cancel silently unlinks — a settling Payment
> Entry (or any other `auto_cancel_exempted_doctypes` voucher) that ERPNext's own blast-radius
> check (`get_submitted_linked_docs`) structurally cannot surface. An unreadable read **refuses the
> whole plan** — deny-biased, the same house rule as every other lock-adjacent read — so **an
> existing broker credential scoped before this release will have EVERY plan_cancel/
> plan_cascade_cancel denied until the grant is added**, whether or not the target document is
> actually settled by anything. The migration step: Role Permission Manager → `Payment Ledger
> Entry` → read permission for the broker's role. `pacioli doctor` now probes exactly this
> (`probe_payment_ledger_read`) and prints the remedy (a 403 there is a FAIL, the same deliberate
> inversion as the Company/Workflow-read probes above).

> **⚠️ UPGRADE — the tight-role seat check needs the `User.get_roles` method (doctor only).**
> `pacioli doctor` now runs a **roles probe** (`probe_roles`): it reads the broker seat's own roles
> and **refuses an install whose seat carries `System Manager`** (or the literal Administrator) —
> that role can administer the governance away (write Custom DocPerm rows, mint API keys, run
> arbitrary code via the System Console), so it voids the least-privilege spine even though frappe
> grants it no runtime permission-bypass (only the literal `Administrator` *username* gets that).
> An `Accounts Manager` seat draws a WARN (over-broad, not spine-voiding). This adds one grant —
> the method `User.get_roles` — to the credential's `pacioli_guard` scope; without it the probe
> reports a 403 FAIL with the remedy (deny-biased: an un-auditable seat cannot be certified
> least-privilege). It is a **doctor** grant only — the governed runtime does not call it, so
> existing governed writes are unaffected. Add it via API Key Scope → `methods` → `User.get_roles`.
> Honest caveat: frappe's `get_roles` honors a `?uid=<user>` param with no permission check, so
> this grant also lets the credential read *any* user's roles (read-only recon, not escalation);
> doctor itself never sends `uid`. Ignoring `uid` bench-side would be a `pacioli_guard` change — a
> separate hardening increment.

> **⚠️ UPGRADE / NEW GRANT — governed reconciliation (F-R2) needs `Payment Reconciliation.reconcile`.**
> The new `plan_reconcile` → marker → `reconcile` tools settle a payment against invoices through
> PLAN → CONSENT → PROVE. The credential needs one new **method** grant: `Payment
> Reconciliation.reconcile` (add it via API Key Scope → `methods` — this is a `pacioli_guard`
> credential grant, **not** a Role Permission Manager row). It's config-only: it rides the guard's
> existing doctype-method scoping (`run_doc_method` resolved on the body doctype), **no guard code
> change**. ⚠️ Grant this to the **broker's own credential ONLY** (which owns and constructs the
> allocation payload from a pinned plan) — never to a general agent credential: ERPNext relinks the
> referenced vouchers with `ignore_permissions=True`, so a holder of this one grant reaches writes on
> those referenced docs with their own roles bypassed (see the guard README's container/tool-DocType
> 2-hop residual). There is **no `doctor` probe** for it — the read-grant probes work because a read is safe
> to *attempt*, but a reconcile is a *write*; it can't be probed without performing it, so the grant
> is documented here rather than probe-enforced. Without the grant, `reconcile` returns a clean 403
> refusal at call time. **Status: live-proven end-to-end on a real ERPNext v16 bench** (PHASE X,
> 2026-07-09, pins P1–P8; broker 0.13.1). The wire shape was corrected in-window off the 0.13.0
> unit-only build — the `invoices[]` pool child-table and the payment-unallocated echo semantics
> were both live-verified — and the closed-books refusal fired where the identical raw `reconcile()`
> **silently posted into a closed period**. Journal-Entry payments, `Unreconcile Payment` (UNDO),
> `Process Payment Reconciliation` (batch), and the per-account `Account.freeze_account` check are
> **deferred**, recorded increments — see `docs/plans/2026-07-09-fr2-govern-reconciliation.md`.

**The minimal seat — a recipe (source-verified against frappe/erpnext v16).** The broker needs
strictly less than `Accounts Manager`, and far less than `System Manager`. The least-privilege seat
is **`Accounts User` + a small custom role** (call it **"Pacioli Seat"**) granting exactly four
things `Accounts User` lacks:

| Grant (Role Permission Manager → the role) | Why |
|---|---|
| `Sales Invoice` → **cancel** | Accounts User can submit but not cancel a Sales Invoice (its docperm has no `cancel`); the broker cancels SIs |
| `Accounts Settings` → **read** | frozen-books / lock reads (`get_period_locks`) |
| `Period Closing Voucher` → **read** | the PCV lock boundary |
| `Workflow` → **read** | the Workflow-SoD gate source |

> **⚠️ OPTIONAL GRANT — the Close's Half 2 (`close --reconcile`) can name a repost with `Repost
> Accounting Ledger` → read.** The reconciliation reads `GL Entry` and `Accounts Settings` (both
> already in the seat above) as its required audit sources, and *additionally* reads the `Repost
> Accounting Ledger` DocType to attribute a second-generation GL row to the repost that caused it.
> That DocType is often System-Manager-gated on a bench, so it is deliberately **not required**:
> without the grant, `close --reconcile` still runs and completes — it just cannot name *which*
> repost produced a second generation (`pacioli doctor`'s repost probe WARNs, it does not FAIL).
> Grant read on `Repost Accounting Ledger` only if you want that attribution.

Everything else the broker touches — submit/cancel/create on Purchase Invoice, Payment Entry,
Journal Entry, and read on GL Entry, Company, Accounting Period, Payment Ledger Entry — `Accounts
User` already grants natively (source-derived from v16 stock DocPerms; the tight seat's own live run
covered the Sales Invoice submit/cancel vertical — the PI/PE/JE writes under this exact seat are
bench-pending, having been live-proven under the broader scoped seat). `Accounts Manager` would also work but is broader than needed: it
adds `delete` on Sales/Purchase Invoice, `write` on the read-only lock sources, and submit/cancel
on Period Closing Voucher itself (closing a period), none of which the broker uses. (`Accounts
User` itself already carries `delete` on Payment Entry and Journal Entry — inherent to the stock
role, not something a seat choice removes; it stays unreachable because `pacioli_guard` never
grants the credential a `delete` verb.) `pacioli doctor`'s roles probe checks by role **name**, so
a bench that clones System-Manager-grade DocPerm rows onto a differently-named custom role would
read as clean — a config-drift caveat that itself requires pre-existing admin access to set up.

One honest note on amend: creating the amended draft is a **resource CREATE** on Sales Invoice
(`POST /api/resource/Sales Invoice`). Guard's resource verbs are now per-credential, so the tight
posture for the broker is `allow_resource` on with the resource verbs narrowed to **read + create**
— it can read Sales Invoice / GL Entry and insert an amended draft, but the same credential cannot
`write` or `delete` a Sales Invoice via raw REST. A draft is reversible and posting still requires
the submit method, so this is the minimum the broker needs and no more. (Verb narrowing is
per-credential today; per-DocType verbs are a future guard increment — until then, read+create
applies across every DocType the broker reads.)

Without this, anything holding the broker's raw credential — the agent's own shell reading the
target registry, a compromised MCP client, a supply-chain-tampered broker — can call ERPNext's
REST API directly and post an invoice with zero PLAN, CONSENT, or PROVE. **If you run the broker
against an unscoped credential, say so plainly: the trust spine is bypassable by anyone holding
that raw credential until `pacioli_guard` is installed and it is scoped.**

**2. The seal key must be protected.** `PACIOLI_SEAL_KEY_FILE` (or its default location under
`PACIOLI_STATE_DIR`) holds the HMAC key that seals every PROVE receipt. It is generated on first
use, written `0600`, and refused at load if it is group- or world-readable. Anyone who can read
that file — including the broker process's own host account — can forge receipts. That is the
precondition PROVE's on-box guarantee rests on, and it is the reason PROVE is not called
tamper-evident against the host itself without the anchor below.

**3. The anchor pin only counts if it actually lives off-box.** `pacioli anchor write` emits the
pin; it cannot move it off this host for you. A pin the broker host can read or rewrite proves
nothing — carry it to another machine, a git remote, or paper. Run `pacioli anchor check` against
the *old* pin before rotating to a new one (rotating over an unchecked pin would launder tampering
as truth), and pin on a cadence you can defend: PROVE is tamper-evident against host-level
truncation or rewrite **since the last pin**, and only that.

## Operator walkthrough — the actual tool + CLI names

The agent side (MCP tools, from `pacioli/tools.py`):

1. **`plan_submit(name="SINV-00001")`** — PLAN. Dry-runs the draft via ERPNext's native ledger
   preview, records the plan, and returns `plan_id`, the projected GL impact, and risk flags.
   Nothing is posted. The response tells the agent: *"have a human mint a consent marker for this
   plan_id (`pacioli mint`), then call `submit_sales_invoice` with it."*

The human side, out of band, in a terminal the agent cannot read:

2. **`pacioli mint <plan_id> --ttl 900`** — mints a single-use consent marker for that recorded
   plan and prints the raw token **once**. Minting is deliberately a CLI command, not an MCP tool:
   if the agent could mint, consent would be self-granted and the marker would be theatre. The
   human hands the printed token to the agent through a channel the agent's own shell can't read.

Back on the agent side:

3. **`submit_sales_invoice(name="SINV-00001", plan_id=<plan_id>, marker=<token>)`** — one of the
   two state-changing tools. It refuses a stale plan (the doc changed since PLAN), a
   closed-books violation, a missing/spent/mismatched marker, or a marker minted for any other
   operation; otherwise it CAS-claims the marker, writes an intent receipt, executes the submit,
   and writes a committed (or, on failure, a failed) outcome and releases the marker.

UNDO runs the same three steps in the opposite direction: **`plan_cancel`** (shows the live GL
rows the cancel would unwind, refuses when other submitted documents link to this one) → the same
out-of-band **`pacioli mint`** → **`cancel_sales_invoice`** (docstatus 1 → 2, identical gates).
The plans are op-bound: a marker minted against a cancel plan cannot authorize a submit, or vice
versa.

After the unwind, the story continues with the **corrected** entry — the cancel → amend →
re-submit arc:

4. **`amend_sales_invoice(name="SINV-00001")`** — creates the corrected re-draft: a new DRAFT
   copied from the cancelled document with `amended_from` set (ERPNext has no native amend server
   method; this is the documented copy → strip → insert-as-draft flow, and the strip-list —
   identity, audit stamps, state, settlement residue, child-row identity — is documented in
   `pacioli/amend.py` as the security surface it is). No marker: the draft is reversible, delete
   undoes it. The intent+outcome receipt pair is still written, so `pacioli verify` shows
   cancel → amend → submit as one arc on one ledger. It refuses an uncancelled source and a
   second amendment of the same document (naming the existing one).
5. The human corrects the draft in ERPNext (or the agent edits it — it's a draft), and
   **submitting it goes back through the front door**: `plan_submit(new_name)` → out-of-band
   `pacioli mint` → `submit_sales_invoice`. The irreversible step never rides the amend.

**CONSENT's second gate — Workflow-SoD** (**live-proven against a real ERPNext v16 bench**, Gate
5): if a company has configured an active ERPNext Workflow on Sales
Invoice, `submit_sales_invoice`/`cancel_sales_invoice` refuse outright whenever that workflow
governs the op — call `workflow_status(name)` first to see the workflow's current state and legal
transitions (each flagged approving/non-approving, with its role and self-approval setting), then
`request_workflow_transition(name, action)` to make a **non-approving** move yourself (e.g.
Draft → Pending Approval). The approving transition — the one that would carry the document to
`doc_status` 1 or 2 — always belongs to a human with the named role and is refused broker-side even
if the credential could technically call it (belt: this refusal; suspenders: frappe's own
`has_approval_access`). No marker on `request_workflow_transition` — it's reversible, a
workflow-state move, never a `docstatus` change — but it writes the same intent+outcome receipt
pair as every other mutation. No workflow configured on the doctype leaves `submit`/`cancel`
exactly as they were before this gate existed.

Operator checks, any time:

- **`pacioli doctor [--target NAME] [--offline]`** — read-only config & readiness checks: the
  registry loads, credential references resolve (values never shown), the state db and seal-key
  file mode are sane (nothing is created), and a one-GET probe confirms the bench answers **as the
  right principal**. A 403 on the method probe is a *pass* (authenticated, scoped tighter than
  the probe — the prescribed posture); authenticating as **Administrator is a failure** (a
  superuser broker credential voids the trust spine). The **roles probe** extends that same rule
  from the username to the *role*: a seat carrying **System Manager** is a FAIL (it can administer
  the governance away — Custom DocPerm writes, API-key minting, System Console code exec), an
  `Accounts Manager` seat draws a WARN, and a 403 (missing the `User.get_roles` grant) is itself a
  FAIL — an un-auditable seat cannot be certified least-privilege. The **belt-exemptions probe**
  watches the three stock fields that silently disable ERPNext's own frozen/closed-period belts
  for a role (`Accounting Period.exempted_role` — which has **no anti-Administrator carve-out** —
  `Company.role_allowed_for_frozen_entries`, legacy `Accounts Settings.frozen_accounts_modifier`):
  an exemption naming a role **this seat carries** is a FAIL (that belt no longer fires for the
  seat's postings — and `exempted_role = "All"` exempts every authenticated seat), one naming a
  role the seat lacks is a WARN, and an unreadable source is a FAIL. All its reads ride grants the
  broker already requires — no new grant. It also runs the
  **workflow-read probe** (the Workflow-SoD gate's source): there a **403 is a FAIL** —
  deliberately the opposite rule, because the gate requires readability — and the finding prints
  the exact grant to add. Run it
  first: the local commands below succeed on a half-configured install because credentials
  resolve at call time — doctor is what tells you the first bench call won't be your error
  message.
- **`pacioli verify [--target NAME] [--expected-head HASH]`** — verifies a target's PROVE receipt
  chain from the operator's side.
- **`pacioli anchor write [--target NAME] [--out PATH|-]`** — emits the off-box pin (stdout
  by default, so *you* decide where off-box it goes; it refuses to pin a chain that does not
  verify). **Since 0.21.0 the pin also covers the seal history, and since 0.26.0 the
  close-record history — one pin, all three chains:** the record carries each table's own head
  hmac + row count, and `anchor write` refuses to pin a history that is not itself fully
  verifiable — a legitimately sealed broker pins normally, and so does a gate honestly latched
  awaiting attestation (a workflow latch over verified rows is not a broken history). **`pacioli
  anchor check --in PATH|- [--target NAME]`** — verifies the live chain against a previously
  recorded pin: keyed chain verify plus the deny-biased anchor comparison, and the same
  deny-biased check against the live seal and close-record histories for the pairs the pin
  carries, naming a tail-truncation or divergence; exit 0 only when every applicable check
  holds. An older pin (v1: no seal or close fields; v2: no close fields) still verifies
  everything it covers and prints a WARNING per uncovered table — never silently treated as if
  it covered them. `pacioli seal-status --anchor PATH|-` runs the same
  seal-side check from the everyday status command (see "The seal (CONTAIN)" above).
- **`pacioli orphans [--target NAME]`** — lists intent receipts with no committed outcome, so they
  can be reconciled by hand against the document's real `docstatus`.
- **`pacioli close [--target NAME] [--since ISO] [--until ISO] [--expected-head HASH] [--reconcile] [--json]`** —
  **the Close**: a period statement built from the receipt chain — every governed act classified
  *committed* / *recorded-open* / *orphan*, summarised by tool/target/doctype, with the chain head,
  verify result, and off-box-anchor comparison. Renders a human-legible statement — the proof made
  readable — or `--json`; exit 0 only when the period closes clean (no orphans, chain verifies, head
  matches any `--expected-head`). The Statement alone attests only to activity that passed **through
  Pacioli** — not the whole ERPNext ledger.
  - **`--reconcile` (the Close, Half 2 — the Reconciliation)** closes that gap: it joins the
    governed acts against a live sweep of ERPNext's actual General Ledger movement for the period
    and sorts every voucher into **governed** / **ungoverned** ("did not pass through Pacioli" —
    stated, never accused) / **second-generation** (a governed voucher carrying rows that don't line
    up, most often a ledger repost). The model is **accounting, not police**: it presents a
    partitioned account and passes no verdict on the ungoverned bucket. Requires a company-pinned
    target and both `--since`/`--until`; exit 0 iff the Statement balances *and* the reconciliation
    is complete (an unreadable audit source refuses; ungoverned movement never flips the code). The
    sweep windows GL rows by **`creation` time** — so to catch a posting *backdated* into a closed
    period, set `--until` past the period-end (e.g. to now), not to the period's last posting date.
    **Not to be confused with the `reconcile` MCP *write* tool (F-R2)**, which settles a payment
    against invoices; `close --reconcile` is a read-only period audit. Design + the honest ceilings
    it cannot close: `docs/plans/2026-07-09-the-close.md`.
- **`pacioli serve`** — runs the agent-facing server (needs `pacioli[server]`). Stdio by
  default; `--http` opens the HTTP door (MCP streamable HTTP); `--a2a` opens the **A2A door**
  (Agent2Agent v1.0 — JSON-RPC, agent card at `/.well-known/agent-card.json`; needs
  `pacioli[a2a]`), the first NON-MCP door — the proof that dispatch is protocol-blind. **A
  door admits; it never decides**: the spine's guarantees (guard floor, out-of-band marker
  consent, PLAN→CONSENT→PROVE, seal, gate) hold identically behind every door, so each one
  reaches the same governed dispatch — and every intent receipt records *which door* and
  *what principal* asked (`via`). Both network doors are deny-biased the same way: bind
  loopback by default; a non-loopback bind **refuses to start** without
  `--auth env:VAR|file:/path` (a bearer token by reference, never inline); with a token set,
  every RPC request needs exactly `Authorization: Bearer <token>` or gets 401 before the
  protocol layer sees it (the A2A agent card stays readable pre-auth — discovery must precede
  authentication — and declares the bearer scheme when one is set). An A2A peer calls a tool
  with a DataPart shaped `{"tool": "<name>", "params": {...}}` and gets the governed dispatch
  dict back as a task artifact — a forged submit is refused at `stage: plan` over A2A exactly
  as over MCP. Both network doors also carry a
  first-class **in-door perimeter** (`pacioli/webguard.py`, wrapping every request outside the
  bearer gate): a **Host-header/DNS-rebind allowlist** (a Host off the allowlist → 400; default
  = the bind host + loopback, widen with `--allowed-hosts`), a **cross-origin guard** on control
  POSTs (`Sec-Fetch-Site: cross-site`/`same-site` → 403, a cross `Origin` → 403, a
  non-`application/json` body → 415 — a browser-CSRF defense that non-browser clients pass
  transparently), and a **request-body size cap** (128 KiB) that buffers-and-checks *before* the
  app runs, so a chunked request that drops `Content-Length` is still refused 413 — stronger than
  a Content-Length check alone. `build_app` independently refuses to construct a public-bound
  door without a token. **Agent-card signing is opt-in** (0.29.0): the
  card is served unsigned by default; point `PACIOLI_A2A_SIGNING_KEY_FILE` at an EC P-256 key
  (`pacioli a2a-keygen` mints one, 0600) and the card is **ES256-signed** with its public key at
  `/.well-known/jwks.json`. The key lives in the operator's tier (same as the seal key and the
  consent marker), 0600, refuse-if-exposed, never auto-minted — the honest ceiling is that a
  signing key only proves authorship if the agent can't rewrite it, so keep it outside the
  agent's write reach, and a peer must pin the public key out-of-band (a card's own `jku` is not
  a trust root). Other honest notes still standing: an A2A door is a standing listener by the
  protocol's nature (acceptable only because doors are opt-in); and **TLS is the perimeter's
  job** — front with a reverse proxy for TLS before any non-local exposure (Host/CORS/size are
  in-door; TLS is the one honest remaining proxy job). Dispatch is serialized (one governed act
  at a time) no matter which door was used.

There are also three read-only MCP tools available to the agent at any time, unrelated to the
write flow: `get_sales_invoice(name)`, `list_sales_invoices(filters, limit)`, and
`workflow_status(name)`.

## Supported doctypes

The broker's tool surface covers **four** doctypes, all riding the exact same generic handlers,
gates, and receipts: **Sales Invoice** and **Purchase Invoice** (both **live-proven** on a real
ERPNext v16 bench — Gates 2–6), **Payment Entry** (**live-proven**, Gate 8), and **Journal Entry**
(**live-proven end-to-end** — governance legs Gate 9, submit/cancel via the guard-scoped
body-doctype path at the Gate 10 close-out, 2026-07-07, `SCOPED-TOKEN-PROOF.md` PHASE M; see the
subsections below). **Thirty tools** in total: five
named siblings per doctype plus the generically-named doc-scoped tools.

**Submit/cancel transport is per-doctype** (`SUPPORTED_DOCTYPES[doctype]["submit_via"]`,
`pacioli/erpnext.py`): Sales Invoice, Purchase Invoice, and Payment Entry ride the proven
`"run_method"` transport (`POST /api/resource/<doctype>/<name>?run_method=submit`/`cancel` — the
URL-path doc-method surface, unaffected by anything below). **Journal Entry alone is
`"client_rpc"`**: `JournalEntry` overrides `submit()`/`cancel()` (background-queuing >100-row
entries) without frappe's `@frappe.whitelist()`, so frappe 403s the `run_method` shape for JE
specifically. JE submit rides `POST /api/method/frappe.client.submit` with the full fetched `doc`
body instead (frappe reconstructs the document from the body — `frappe.get_doc(doc); doc.submit()`
— rather than re-reading it from the DB, so the broker must send the SAME doc its freshness/
closed-books/balance gates already validated, never a fresh re-fetch); JE cancel rides
`frappe.client.cancel` with plain `{"doctype", "name"}` params (no doc body — it loads fresh from
the DB itself). Both are **body-doctype** RPCs (the doctype travels in the request body, not the
URL) — safe to enable only because `pacioli_guard` ≥ 0.5.0 parses that body
(`scope.body_scoped_target`) and enforces the credential's per-doctype grant on it exactly as
strictly as the `run_method` shape. **Hard precondition:** do not run this path against a
credential scoped under guard <0.5.0.

The Purchase Invoice increment (Gate 6) grew the surface from eleven tools to sixteen: the five
existing `*_sales_invoice` tools keep their name,
schema, and behaviour (the `submit`/`cancel` descriptions gained a clause about the new
cross-doctype refusal), and five new siblings — `get_purchase_invoice`,
`list_purchase_invoices`, `submit_purchase_invoice`, `cancel_purchase_invoice`,
`amend_purchase_invoice` — wrap the *same* generic handlers pinned to Purchase Invoice (no
duplicated logic). The four generically-named doc-scoped tools — `plan_submit`, `plan_cancel`,
`workflow_status`, `request_workflow_transition` — gained an optional `pacioli_doctype` argument
(default `"Sales Invoice"`, today's behaviour when omitted); passing `"Purchase Invoice"` plans or
reads against that doctype instead. A `pacioli_doctype` outside the supported set is refused
before any network call, naming what is supported.

**Two allowlists, doing different jobs — belt-and-suspenders, not redundant:**
- **The broker's own `SUPPORTED_DOCTYPES`** (`pacioli/erpnext.py`) is "I've been built and tested
  for these" — the four doctypes above (e.g. Sales Invoice with `party_field="customer"`;
  Journal Entry carries no header-level party at all). A doctype outside it is a structured deny
  at the tool layer, regardless of what the credential itself could reach.
- **`pacioli_guard`'s per-credential `resource_doctypes` grant** is "this credential may touch
  these ERPNext DocTypes at all" — an operator-configured scope on the bench side. Either layer
  denying is enough to refuse a call; neither substitutes for the other.

**The security-critical addition: the plan is now bound to its doctype.** A plan recorded by
`plan_submit(pacioli_doctype="Sales Invoice")` can never be presented to
`submit_purchase_invoice` (or vice versa) — `pacioli/plan.py`'s `check_doctype` refuses the
mismatch, wired into the same governed-write gate as the existing cross-op guard (a cancel marker
cannot authorize a submit). Proven in both directions in `pacioli/tests/test_tools.py`.

**The genuine Purchase Invoice differences**, all threaded through explicitly rather than
assumed: `list_purchase_invoices` returns `supplier`, not `customer`; the GL-entries read behind
`plan_cancel` now filters on `voucher_type` as well as `voucher_no` (closes a latent cross-doctype
read gap once two doctypes can share one GL Entry table); `pacioli doctor`'s workflow-read probe
checks readability for **both** doctypes, independently, since a company's Role Permission
Manager grant can differ per doctype.

### Payment Entry breadth — a third doctype (LIVE-PROVEN, Gate 8, 2026-07-06)

Twenty-three tools now, up from eighteen: five new siblings — `get_payment_entry`,
`list_payment_entries`, `submit_payment_entry`, `cancel_payment_entry`, `amend_payment_entry` —
wrap the same generic handlers pinned to Payment Entry (`party_field="party"`; an Internal
Transfer payment carries no party at all, surfaced as an absent field like any other doctype's
missing value). `pacioli_doctype="Payment Entry"` is now accepted by the four generically-named
doc-scoped tools, refused before this build exactly like any other unsupported doctype.

**Two Payment-Entry-specific disclosures, both advisory (never a gate), both read from the
draft's own cached `references` child rows (no extra bench call):**
- `plan_submit` flags any reference row with a nonzero `exchange_gain_loss` — ERPNext's own
  ledger-preview call creates AND SUBMITS a real, separate Exchange Gain/Loss Journal Entry
  mid-preview (rolled back only at the very end), and the projection never shows that JE's own GL
  rows — projection-incomplete, disclosed rather than silently trusted. It also flags any
  reference already at zero/negative `outstanding_amount`: ERPNext itself only warns
  (`frappe.msgprint`, HTTP 200, no exception) about posting a payment against an
  already-settled invoice — a governed PLAN surfaces what the bench only warns about.
- `plan_cancel` discloses the blast radius a single Payment Entry cancel carries: unlike SI/PI's
  one-document cancel, one voucher can revert `outstanding_amount` on N invoices at once. The
  response's `references` key lists every reference (doctype, name, allocated_amount); the
  `get_gl_entries` read now also carries `against_voucher_type`/`against_voucher` so the projected
  reversal rows say which invoice each is against (a plain field-list addition, benefits SI/PI
  reads too).

### Journal Entry breadth — a fourth doctype (governance legs LIVE-PROVEN Gate 9; submit/cancel built via guard-scoped body-doctype path, Gate 10 staged)

> **Found live (Gate 9, 2026-07-06); closed in 0.9.0.** Journal Entry's **plan** and its two
> refusals — the Exchange-Gain-Or-Loss reserved-voucher deny and the independent debit==credit
> balance check — are **live-proven** (both fired against the bench). `submit_journal_entry` and
> `cancel_journal_entry` were **blocked**: ERPNext's `JournalEntry` overrides `submit()`/`cancel()`
> (to background-queue >100-row entries) and drops frappe's `@frappe.whitelist()`, so frappe
> rejected the broker's *original* guard-scopeable `run_method=submit` shape with a 403.
> Sales/Purchase Invoice and Payment Entry override neither and are unaffected. **JE submit/cancel
> now ride `frappe.client.submit`/`.cancel`** (the doctype-in-body RPC surface) instead — safe to
> enable *only* because `pacioli_guard` 0.5.0 closed its own matching residual, parsing that body
> and enforcing the credential's per-doctype grant on it exactly as strictly as the `run_method`
> shape (see `SUPPORTED_DOCTYPES[...]["submit_via"]` above). **Live-proven 2026-07-07 (Gate 10
> close-out, PHASE M):** JE submit 0→1 / cancel 1→2 through this transport on the real bench, and —
> because an invoice's real cascade dependents surface as Journal Entries — the exact PHASE J-2
> cascade graph that used to fail-stop at its JE node now completes 2/2.

Twenty-eight tools now, up from twenty-three: five new siblings — `get_journal_entry`,
`list_journal_entries`, `submit_journal_entry`, `cancel_journal_entry`, `amend_journal_entry` —
wrap the same generic handlers pinned to Journal Entry. Journal Entry is the first doctype with
**no header-level party field at all** (only per-line party in its `accounts` child table,
confirmed from `journal_entry.json`) — `party_field=None`, and `list_journal_entries` carries no
`party` column, no `status` (Journal Entry has none; `docstatus` is its only status signal), and
no `grand_total` (its own `total_debit`/`total_credit` stand in).

**Journal Entry is also the first doctype to earn its own GATE, not just a disclosure** — its
ERPNext controller carves out a real, source-confirmed bypass of Pacioli's founding law ("no debit
without a credit") that Sales/Purchase Invoice and Payment Entry never could:

- **`voucher_type == "Exchange Gain Or Loss"` is REFUSED outright**, at both `plan_submit` and
  `submit_journal_entry` — two independent ERPNext gates
  (`validate_total_debit_and_credit`, `general_ledger.process_debit_credit_difference`) skip the
  debit==credit check for exactly this value, since it's meant to be produced only by ERPNext's own
  FX-revaluation tooling. Cancel is deliberately NOT refused for it — ERPNext's own machinery
  routinely auto-cancels these Journal Entries as a side effect of cancelling whatever they
  reference.
- **An INDEPENDENT balance check** — the broker sums the draft's own `accounts` child-row
  `debit`/`credit` fields itself (never trusting ERPNext's own cached `total_debit`/`total_credit`)
  and refuses a mismatch, at both `plan_submit` (before any marker can even be minted) and
  `submit_journal_entry` (belt-and-suspenders, even though `check_fresh` already makes the second
  check logically redundant for an unmodified draft). Honest edge: the broker's tolerance
  (`0.005`) is deliberately tighter than ERPNext's own round-off allowance (~`0.05` at 2-decimal
  precision, where it silently inserts a round-off GL row) — so a draft ERPNext would legally
  post with an auto round-off can be refused here. Fail-safe by construction: it denies, never
  admits; loosen only if a real bench shows legitimate drafts being refused.

Two more advisory disclosures (never a gate):
- `plan_submit` carries a standing note that Journal Entry's `on_submit`-only checks (cheque info,
  credit limit, invoice-discounting status) are invisible to the native preview, plus a conditional
  flag when a Bank Entry draft is missing `cheque_no`/`cheque_date` (ERPNext's own
  `validate_cheque_info` requires both for `voucher_type == "Bank Entry"` specifically, checked
  only at on_submit) — a Cash Entry draft missing the same fields is flagged too, worded honestly
  as the broker's own precaution rather than an ERPNext-enforced rule.
- `plan_cancel` reads `Accounts Settings.unlink_payment_on_cancellation_of_invoice` (a new
  read — an unreadable settings doc refuses the whole plan, deny-biased like every other
  lock-adjacent read here) and flags it ON: turning cancel's blast radius from "refused by the
  generic backlink check" into "a silent raw-SQL unlink of other submitted Journal Entries/Payment
  Entries that reference this one". A second flag is always present: cancelling a Journal Entry
  auto-cancels any system-generated Exchange Gain Or Loss Journal Entry that references it, with no
  separate consent.

## Honest scope

- **PLAN** — full (native ERPNext preview), but a *preview*, not a guarantee: server-side
  validation still runs again at submit time.
- **CONSENT** — two gates. A real out-of-band human marker, single-use and concurrency-safe
  (reserved and CAS-claimed before execute; released on a failed submit). Plus **Workflow-SoD**:
  when a company has configured an active ERPNext Workflow, `submit`/`cancel` refuse outright
  whenever it governs the op — **live-proven against a real ERPNext v16 bench** (Gate 5). It
  governs only this broker's own path: frappe does not enforce Workflow on a direct `docstatus`
  change, so bench-side enforcement against every other calling path is still a future increment,
  not implied by this gate.
- **RECONCILE (F-R2)** — governed Payment Reconciliation (`plan_reconcile` → marker → `reconcile`):
  the broker owns the payload (constructs it from the pinned plan, never agent input), enforces its
  own **cumulative** over-allocation ceiling + a closed-books refusal (ERPNext bypasses the latter
  for reconciliation), and confirms by reading the effect back. **Live-proven end-to-end on a real
  ERPNext v16 bench** (PHASE X, 2026-07-09, pins P1–P8): the wire shape was corrected in-window
  (0.13.1 — the `invoices[]` pool + the payment-unallocated echo semantics, both live-verified),
  a governed settle committed with the bench's own Payment Ledger moving, the closed-books refusal
  fired where the identical raw `reconcile()` **silently posted into a closed period** (the bypass
  proven live, not just source-cited), and the multicurrency leg had ERPNext author the
  exchange-loss JE in the scoped seat's own hand exactly as the plan disclosed pre-consent.
  Payment-Entry-against-invoice only this slice; JE-payments, UNDO/unreconcile, the batch
  path, and the per-account `Account.freeze_account` check are deferred, recorded increments.
- **PROVE** — a hash-chained receipt plus the **off-box head anchor** (`pacioli anchor
  write`/`check`). On-box alone, the chain is tamper-evident against someone calling ERPNext's
  own API but **not** against anyone with file access on this host — including the agent this
  ledger watches (`verify_chain` detects mid-chain tampering, reordering, or a dropped entry, but
  not a tail-truncation or a full wipe). With a disciplined off-box pin, that upgrades to
  tamper-evident against host-level truncation or rewrite **since the last pin** — the window
  between pins stays unprotected, the seal key is still on-box, and keeping the pin off-box is
  the operator's discipline, not the tool's.
- **LIST** — `list_documents` returns a page with no pagination signal (no `has_more` flag or
  next-page indicator); a caller cannot distinguish a full page from the end of results.
- **UNDO** — the governed **cancel**, the **amend** re-draft (the full submit → cancel → amend →
  resubmit arc, PHASE F — **site-added custom fields, including those of fieldtype `Password`,
  are copied into the amended draft** where ERPNext's native amend UI would omit them), and
  **cascade cancel** (`plan_cascade_cancel` → `cascade_cancel`, governs the target plus its
  whole submitted-dependent graph in one consent — PHASE J + Gate 10 M-12) are all
  **live-proven**; the 0.9.3 per-node confirm + disclosure additions await their bench window
  (envelope E3).
- **Doctypes** — four now. Sales Invoice, Purchase Invoice, and Payment Entry are **live-proven**
  (Gates 2/3/6/8). Journal Entry's **governance legs are live-proven** (Gate 9 — the
  Exchange-Gain-Or-Loss refusal and the independent balance check both fired against the bench),
  and its **submit/cancel are live-proven too** (2026-07-07, Gate 10 close-out — the
  `frappe.client.submit`/`.cancel` transport exercised end-to-end on the real bench, 0→1→2,
  transport confirmed verbatim in the request log; `SCOPED-TOKEN-PROOF.md` PHASE M). Journal
  Entry also carries the one gate in this whole broker that isn't just an existing pillar riding a
  new doctype: no lone,
  unbalanced entry reaches this broker's consent path, not even the one ERPNext itself would let
  through a side door — `voucher_type="Exchange Gain Or Loss"` is refused outright at plan and
  submit (ERPNext's own controller waives its own debit==credit check for exactly this value), and
  every Journal Entry submit is independently balance-checked against the draft's own account
  rows, never ERPNext's cached totals, before a marker can even be minted. That refusal and that
  balance check are **live-proven** — the EG refusal fired at plan (PHASE L, re-confirmed PHASE M),
  and the balance check refused a valid-marker write on a doc unbalanced after planning with
  `modified` untouched (the TOCTOU belt, PHASE M-2).
- **The frozen-books lock now reads the right v16 field.** `get_period_locks` used to read only
  the legacy `Accounts Settings.acc_frozen_upto` — a field ERPNext v16 migrated off entirely — so
  the lock was silently always-absent for *every* doctype already shipped, not only the ones
  landing in this release. It now also reads `Company.accounts_frozen_till_date` and honors
  whichever date is later. **Breaking for existing scoped credentials** — see Preconditions above
  for the new `Company` read grant this requires.

PROVE is never called tamper-evident without that qualification, and no leg of UNDO is claimed
live-proven before it has actually run against a real bench.

Multi-site/company routing (`pacioli_target=`) is plumbed from day one so that wrong-company books
are **designed to be unreachable** — a plan records the target it was built against and refuses a
replay at a different target, and a target pinned to a company refuses to plan a document from any
other company. That routing is proven against one target in slice-one; it has not been exercised
against more than one.

That wrong-books pin is re-checked at **execute**, too, not just at plan — for a single submit/
cancel (0.10.1/F-C2) and for every node of a cascade cancel (0.7.0's `_cascade_books_gate`). A
company change between plan and execute doesn't necessarily bump a document's `modified`, so the
TOCTOU freshness check alone can't be relied on to catch a company swapped underneath a live plan
(a `db_set(update_modified=False)`/raw-SQL patch); both execute paths re-read the document's live
company and refuse a mismatch, never trusting only the plan-time pass.

## Configuration

Three environment variables, all read by the CLI and the server:

| Variable | Required | Meaning |
|---|---|---|
| `PACIOLI_REGISTRY` | yes | Path to a TOML file listing routed targets (below). |
| `PACIOLI_STATE_DIR` | yes | Directory holding one SQLite file per target (the PROVE ledger + plans + markers) and, by default, the seal key. |
| `PACIOLI_SEAL_KEY_FILE` | no | Overrides where the HMAC seal key lives (default: `PACIOLI_STATE_DIR/seal.key`). Must stay `0600`. |

`targets.toml` — secrets are held **by reference only** (`env:VAR` or `file:/path`); a literal
secret in this file is refused at load and never echoed back in the error:

```toml
[targets.prod]
base_url   = "https://erp.example.com"
api_key    = "env:PACIOLI_PROD_API_KEY"
api_secret = "env:PACIOLI_PROD_API_SECRET"
company    = "Example Co"
seat_user  = "pacioli-seat@example.com"   # optional — see below
site_tz    = "America/Chicago"            # optional — see below
default    = true
```

`api_key` is an identifier, not a secret, so it may be inline or a reference; `api_secret` must
always be `env:` or `file:`. `company` scopes a target to one company (required for `close
--reconcile`). `seat_user` (optional) is the ERPNext username this credential authenticates as; when
set, `close --reconcile` corroborates that a governed voucher's GL rows were actually stamped by that
user — a voucher whose rows were rewritten under a different name (e.g. an admin's ledger repost)
downgrades to second-generation. It is purely tightening: absent → that corroboration is simply off.

`site_tz` (optional) is the IANA timezone the ERPNext site's wall clock runs in. Pacioli lives
across two clocks — its own receipt stamps are UTC, ERPNext's `creation`/`modified` stamps are the
site's wall clock — and a `close --since/--until` window crosses both. Declared, window bounds mean
**site time** (the books' own calendar: "close July" means July where the books live), converted
once at the CLI boundary; the render then carries a `clock:` line naming the zone. Absent, the
window string is applied verbatim to both clocks — every boundary skewed by the site's UTC offset
between the statement side and the reconcile side — and `close --reconcile` says so in its output.
A zone typo refuses when a window is converted (with the zone named), never when unwindowed.

## License

Apache-2.0.


---

<sub>VENETIIS MCDXCIV · IN THE WORKSHOP OF JOHN BROADWAY · MMXXVI</sub>
