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: 51 governed doctypes, 265 tools — the full submittable transaction surface of an
ERPNext company: accounts, stock, assets, manufacturing, subcontracting, maintenance, and the
control-plane rows (Budget, Timesheet, Contract, Pick List, Production Plan). Every row landed
from a v16 source walk with its own risk-flag disclosures, closed-books date pin, and cascade
shape. 48 of the 51 are live-proven end-to-end on a real ERPNext v16 bench as a scoped
non-Administrator seat under pacioli_guard — the full governed vertical
each time (draft as the seat → plan → out-of-band marker → submit → replay refused → cancel),
including a 3-node Asset cascade under one consent, the armed-Budget control-plane probe (the
belt disclosed at plan, the bench refusing the Purchase Order with the literal BudgetError,
cancel disarming it, the same PO then passing), and the Timesheet second-writer probe (a Sales
Invoice's submit rewriting a submitted Timesheet — disclosed before consent, confirmed live,
reversed on cancel). Governed Payment Reconciliation rides the same spine (PHASE X,
2026-07-09). A certified least-privilege reference seat (Accounts User + a custom "Pacioli
Seat" role) is proven live and certified by doctor across the broker's whole read surface. 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, 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. Every write — submit, cancel, amend, cascade, reconcile, across all 51 governed
doctypes — rides one spine: PLAN → CONSENT → execute → PROVE, plus a permission-scoped 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, the credential-scope floor this broker sits on and binds
itself to.
The trust spine
(The table below narrates each pillar on the founding doctype, Sales Invoice — the shape is
identical for every governed doctype.)
| 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 read tier (get / list, every governed doctype), permission-scoped under the caller's own ERPNext role; pacioli doctor certifies the seat and the chain end-to-end. | Broader 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. |
Party-history disclosure (PLAN, opt-in, off by default). Set PACIOLI_PARTY_HISTORY=1 and
plan_submit adds a context line (or several) to the recorded plan for every doctype with a
configured amount field. As of this build that is Sales Invoice, Purchase Invoice and Payment
Entry (SUPPORTED_DOCTYPES's own amount_field config); run pacioli doctor for the current list
rather than trusting this sentence, since doctor names the doctypes straight off that config and
this prose has no way to stay in sync the next time the set changes. The context states how many
prior submitted documents exist for this same party in these books, the prior amount range, and
where this document's own amount falls against it. At most one risk flag fires, on a brand-new
counterparty or an amount well outside the party's own prior range. pacioli mint renders the
context to the human, before the risk flags, at the one moment consent is actually given (see the
walkthrough below); pacioli doctor also reports whether the flag is on, so a plan carrying no
context is attributable to the feature being off rather than to there being nothing to say. Advisory only:
it is off unless the env var is set, adds nothing but context/at most one flag (never changes
whether a plan is produced or any of its other fields), and a failed read degrades to a stated
"history unavailable: <reason>" line rather than ever failing the plan.
Two different audiences, two different levels of detail (fix round 1). risk_flags is echoed
straight back to the calling agent in plan_submit's own JSON response; context reaches only the
human, via pacioli mint's render. The full parameterised numbers (the multiple, the prior max,
the K threshold, n) stay in context only — a constrained agent that could read its own exact
distance from the flagging threshold in its own tool response would have a closed-form recipe for
what magnitude stays quiet. So the agent-visible flag names only the kind and the party
(AMOUNT OUTSIDE RANGE for <party>; see the plan context. /
NOVEL COUNTERPARTY: <party>; see the plan context.) and points at the plan the human is about to
be asked to consent to; the human still gets every number, unchanged.
🔴 This is disclosure to a human, not enforcement. It reads data the governed credential can
already read (this party's own prior submitted documents), the same read boundary every other
disclosure in this broker sits behind. It is not a control: it cannot permit, refuse, or alter a
write, and possession of the governed credential is exactly as powerful with the flag on as with it
off. Never describe it as protection — it is a second pair of eyes on content, not a boundary.
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 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
pip install pacioli
The CLI (pacioli mint / verify / orphans / anchor / doctor) is stdlib-only. The agent-facing MCP server needs
one extra dependency:
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 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:
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 consent boundary: the broker's state dir and mint CLI must be unreachable by the agent.
CONSENT rests on a human minting the marker out of band. pacioli mint is a CLI, not an MCP tool, on
purpose, and it opens the marker store keyless so the seal key never comes near the consent step.
But that boundary is only real if the agent cannot run mint itself or read/write
PACIOLI_STATE_DIR. If the agent's shell shares a host and OS user with the broker's state, it can
mint its own consent and PLAN → CONSENT is theatre. Run the broker in its own container — or at
minimum its own OS user with a 0700 state dir — separate from wherever the agent's shell lives.
This is the single placement decision that makes the trust story real; every other precondition here
assumes it.
3. 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.
4. 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
If the site sets require_consent (pacioli-guard 0.7.0+), there is a step 0, on the books box:
-
bench --site <site> execute pacioli_guard.mint.mint_consent_marker — mints the FLOOR
marker, a Pacioli Consent Marker bound to a document and an act, by a principal other than the
broker's credential. Since guard 0.13.0 the floor gates the ledger preview as well as the submit,
so this must exist before step 1, and its token is passed to plan_submit as consent_token.
The preview does not spend it, so the same marker then spends on the submit itself.
These are two different markers and they are minted at opposite ends. The floor marker binds a
document and comes first. The broker marker below binds a plan_id and comes after the plan.
Requires broker 0.34.0; earlier brokers cannot present a floor marker at all.
The agent side (MCP tools, from pacioli/tools.py):
plan_submit(name="SINV-00001", consent_token=<floor token, if gated>) — 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 lands: on a perpetual-inventory site the preview really does
post and then roll the transaction back, which is why the floor gates it. 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:
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:
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:
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).
- 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" — today all 51 governed doctypes, each with its own source-verified descriptor
(e.g. Sales Invoice with party_field="customer"; Journal Entry carries no header-level party
at all; BOM and Pick List are declared dateless). 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 — the full governed surface is 51 (see Supported doctypes below and
SCOPED-TOKEN-PROOF.md for the 48-of-51 live-proof status; the three not yet bench-pinned are named
there, not hidden — two design-complete builds awaiting a bench window and one disclosed upstream
ERPNext core limitation). The four called out here carry the deepest per-pillar detail: 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 at a time; 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
Four environment variables, all read by the CLI and the server:
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. |
PACIOLI_PARTY_HISTORY | no | Set to exactly 1 to turn on PLAN-time party-history disclosure (see the trust spine's PLAN row above). Off by default; advisory only. |
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:
[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"
site_tz = "America/Chicago"
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.
VENETIIS MCDXCIV · IN THE WORKSHOP OF JOHN BROADWAY · MMXXVI