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
Project description
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. 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, 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 newCompanyread grant, see Preconditions; it was silently reading a field ERPNext v16 no longer has, for every doctype already shipped) is proven viapacioli doctorFAIL→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.mdPHASE 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 —JournalEntryoverridessubmit()/cancel()without frappe's whitelist, so the broker's originally-only submit shape (run_methodon the URL-path doc-method surface) is rejected — and now ridefrappe.client.submit/.cancel(the doctype-in-body RPC surface) instead, safe to enable only becausepacioli_guard0.5.0 closed its matching residual (scope.body_scoped_targetparses that body and enforces the credential's per-doctype grant on it, exactly as strictly as therun_methodshape). SI/PI/PE are unchanged (regression re-proven live in the same window). Live-proven 2026-07-07: JE submit 0→1 landed verbatim asfrappe.client.submitin 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, 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 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_previewmethod (PLAN's dry-run), - the
frappe.desk.form.linked_with.get_submitted_linked_docsmethod (UNDO's blast-radius read), - the submit and cancel methods on Sales Invoice (
run_method=submit/run_method=cancel), - the
User.get_rolesmethod (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
referencesrows 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, notrun_method. The credential needs read access on Journal Entry, PLUS the method namesfrappe.client.submit/.cancelallowlisted (pacioli_guard'smethodsgrant) AND the per-doctype patternJournal Entry.submit/Journal Entry.cancel(guard 0.5.0'sbody_scoped_targetrewrites the generic RPC name to this per-doctype target before enforcing — holding only the barefrappe.client.submit/.cancelmethod name is no longer enough to submit/cancel ANY doctype through it, by design; seepacioli_guard/CHANGELOG.md0.5.0). No NEW Accounts Settings grant is needed beyond what every other doctype's governed write already requires (get_period_locksreads 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.mdPHASE M).
⚠️ UPGRADE / BREAKING — the Workflow-SoD gate needs a new read grant. Every governed
submitandcancelnow reads theWorkflowDocType 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.Workflowis 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 doctornow 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_transitionadditionally needs the whitelistedfrappe.model.workflow.apply_workflowmethod reachable.
⚠️ UPGRADE / BREAKING — the frozen-books lock needs a new
Companyread grant.get_period_locksnow readsCompany.accounts_frozen_till_dateas the primary frozen-till-date source (ERPNext v16 migrated this offAccounts Settings.acc_frozen_upto— the field the broker read before this fix, which is silently absent on a v16 bench). An unreadableCompanydoc 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 doctornow 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 Entryread grant. Every doctype'splan_cancel/plan_cascade_cancelnow reads Payment Ledger Entry (get_settling_references) to disclose what a cancel silently unlinks — a settling Payment Entry (or any otherauto_cancel_exempted_doctypesvoucher) 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 doctornow 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_rolesmethod (doctor only).pacioli doctornow runs a roles probe (probe_roles): it reads the broker seat's own roles and refuses an install whose seat carriesSystem 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 literalAdministratorusername gets that). AnAccounts Managerseat draws a WARN (over-broad, not spine-voiding). This adds one grant — the methodUser.get_roles— to the credential'spacioli_guardscope; 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'sget_roleshonors 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 sendsuid. Ignoringuidbench-side would be apacioli_guardchange — a separate hardening increment.
⚠️ UPGRADE / NEW GRANT — governed reconciliation (F-R2) needs
Payment Reconciliation.reconcile. The newplan_reconcile→ marker →reconciletools 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 apacioli_guardcredential grant, not a Role Permission Manager row). It's config-only: it rides the guard's existing doctype-method scoping (run_doc_methodresolved 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 withignore_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 nodoctorprobe 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,reconcilereturns 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 — theinvoices[]pool child-table and the payment-unallocated echo semantics were both live-verified — and the closed-books refusal fired where the identical rawreconcile()silently posted into a closed period. Journal-Entry payments,Unreconcile Payment(UNDO),Process Payment Reconciliation(batch), and the per-accountAccount.freeze_accountcheck are deferred, recorded increments — seedocs/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 withRepost Accounting Ledger→ read. The reconciliation readsGL EntryandAccounts Settings(both already in the seat above) as its required audit sources, and additionally reads theRepost Accounting LedgerDocType 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 --reconcilestill 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 onRepost Accounting Ledgeronly 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):
plan_submit(name="SINV-00001")— PLAN. Dry-runs the draft via ERPNext's native ledger preview, records the plan, and returnsplan_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 callsubmit_sales_invoicewith 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 withamended_fromset (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 inpacioli/amend.pyas the security surface it is). No marker: the draft is reversible, delete undoes it. The intent+outcome receipt pair is still written, sopacioli verifyshows 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-bandpacioli 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), anAccounts Managerseat draws a WARN, and a 403 (missing theUser.get_rolesgrant) 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, legacyAccounts 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 — andexempted_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, andanchor writerefuses 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 realdocstatus.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 bycreationtime — so to catch a posting backdated into a closed period, set--untilpast the period-end (e.g. to now), not to the period's last posting date. Not to be confused with thereconcileMCP write tool (F-R2), which settles a payment against invoices;close --reconcileis 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 (needspacioli[server]). Stdio by default;--httpopens the HTTP door (MCP streamable HTTP);--a2aopens the A2A door (Agent2Agent v1.0 — JSON-RPC, agent card at/.well-known/agent-card.json; needspacioli[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 exactlyAuthorization: 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 atstage: planover 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 crossOrigin→ 403, a non-application/jsonbody → 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 dropsContent-Lengthis still refused 413 — stronger than a Content-Length check alone.build_appindependently 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; pointPACIOLI_A2A_SIGNING_KEY_FILEat an EC P-256 key (pacioli a2a-keygenmints 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 ownjkuis 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 withparty_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-credentialresource_doctypesgrant 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_submitflags any reference row with a nonzeroexchange_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/negativeoutstanding_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_canceldiscloses the blast radius a single Payment Entry cancel carries: unlike SI/PI's one-document cancel, one voucher can revertoutstanding_amounton N invoices at once. The response'sreferenceskey lists every reference (doctype, name, allocated_amount); theget_gl_entriesread now also carriesagainst_voucher_type/against_voucherso 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_entryandcancel_journal_entrywere blocked: ERPNext'sJournalEntryoverridessubmit()/cancel()(to background-queue >100-row entries) and drops frappe's@frappe.whitelist(), so frappe rejected the broker's original guard-scopeablerun_method=submitshape with a 403. Sales/Purchase Invoice and Payment Entry override neither and are unaffected. JE submit/cancel now ridefrappe.client.submit/.cancel(the doctype-in-body RPC surface) instead — safe to enable only becausepacioli_guard0.5.0 closed its own matching residual, parsing that body and enforcing the credential's per-doctype grant on it exactly as strictly as therun_methodshape (seeSUPPORTED_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 bothplan_submitandsubmit_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
accountschild-rowdebit/creditfields itself (never trusting ERPNext's own cachedtotal_debit/total_credit) and refuses a mismatch, at bothplan_submit(before any marker can even be minted) andsubmit_journal_entry(belt-and-suspenders, even thoughcheck_freshalready 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.05at 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_submitcarries a standing note that Journal Entry'son_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 missingcheque_no/cheque_date(ERPNext's ownvalidate_cheque_inforequires both forvoucher_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_cancelreadsAccounts 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/cancelrefuse 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 directdocstatuschange, 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 — theinvoices[]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 rawreconcile()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-accountAccount.freeze_accountcheck 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_chaindetects 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_documentsreturns a page with no pagination signal (nohas_moreflag 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/.canceltransport exercised end-to-end on the real bench, 0→1→2, transport confirmed verbatim in the request log;SCOPED-TOKEN-PROOF.mdPHASE 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 withmodifieduntouched (the TOCTOU belt, PHASE M-2). - The frozen-books lock now reads the right v16 field.
get_period_locksused to read only the legacyAccounts 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 readsCompany.accounts_frozen_till_dateand honors whichever date is later. Breaking for existing scoped credentials — see Preconditions above for the newCompanyread 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:
[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.
VENETIIS MCDXCIV · IN THE WORKSHOP OF JOHN BROADWAY · MMXXVI
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pacioli-0.30.2.tar.gz.
File metadata
- Download URL: pacioli-0.30.2.tar.gz
- Upload date:
- Size: 330.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
65d210ac227a317346f2a8c71cd49de015b624670ba568110e52163f6eceac78
|
|
| MD5 |
02e10e739b30e6f04bbe2c90c56d536d
|
|
| BLAKE2b-256 |
6ca7f21e322ff7163b5a4bd1ffb2dc81c84938a95a10705017cfde03d7878bc0
|
Provenance
The following attestation bundles were made for pacioli-0.30.2.tar.gz:
Publisher:
release-pypi.yml on john-broadway/pacioli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pacioli-0.30.2.tar.gz -
Subject digest:
65d210ac227a317346f2a8c71cd49de015b624670ba568110e52163f6eceac78 - Sigstore transparency entry: 2193373688
- Sigstore integration time:
-
Permalink:
john-broadway/pacioli@5c4420957f368a407de70c69e965b8a61151eaad -
Branch / Tag:
refs/tags/v0.30.2 - Owner: https://github.com/john-broadway
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-pypi.yml@5c4420957f368a407de70c69e965b8a61151eaad -
Trigger Event:
release
-
Statement type:
File details
Details for the file pacioli-0.30.2-py3-none-any.whl.
File metadata
- Download URL: pacioli-0.30.2-py3-none-any.whl
- Upload date:
- Size: 286.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7af9a9fdc985a2e3393bd8f34ff588f83b81c7b5311d4d49bfb3f971f748d02d
|
|
| MD5 |
70a763a5e612c9c3684d3fa467b9fdd9
|
|
| BLAKE2b-256 |
cd8554a3a1ba5c9393a89dccfeb1ec06f8b707169b56cf3ca33ab79267a7c708
|
Provenance
The following attestation bundles were made for pacioli-0.30.2-py3-none-any.whl:
Publisher:
release-pypi.yml on john-broadway/pacioli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pacioli-0.30.2-py3-none-any.whl -
Subject digest:
7af9a9fdc985a2e3393bd8f34ff588f83b81c7b5311d4d49bfb3f971f748d02d - Sigstore transparency entry: 2193373693
- Sigstore integration time:
-
Permalink:
john-broadway/pacioli@5c4420957f368a407de70c69e965b8a61151eaad -
Branch / Tag:
refs/tags/v0.30.2 - Owner: https://github.com/john-broadway
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-pypi.yml@5c4420957f368a407de70c69e965b8a61151eaad -
Trigger Event:
release
-
Statement type: