Skip to main content

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

The printer's device

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

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

Status: four doctypes — Sales Invoice, Purchase Invoice, Payment Entry, Journal Entry — are live-proven end-to-end on a real ERPNext v16 bench: governed submit / cancel / amend, cascade cancel, the workflow-SoD gate, and governed Payment Reconciliation (PHASE X, 2026-07-09), all as a scoped non-Administrator seat under pacioli_guard. 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 new Company read grant, see Preconditions; it was silently reading a field ERPNext v16 no longer has, for every doctype already shipped) is proven via pacioli doctor FAIL→PASS. Cascade cancel (0.7.0): a discovery-shape bug was found and fixed live (0.8.1); its mechanism — ordered discovery, fail-stop, marker-once, the happy path — is proven. Journal Entry's submit/cancel (0.9.0): BLOCKED → built via the guard-scoped body-doctype path → LIVE-PROVEN (2026-07-07, Gate 10 close-out — SCOPED-TOKEN-PROOF.md PHASE M). Its governance legs are live-proven (the Exchange-Gain-Or-Loss refusal and the independent debit==credit check both fired live, PHASE L). Its submit/cancel were blocked on a real ERPNext incompatibility — JournalEntry overrides submit()/cancel() without frappe's whitelist, so the broker's originally-only submit shape (run_method on the URL-path doc-method surface) is rejected — and now ride frappe.client.submit /.cancel (the doctype-in-body RPC surface) instead, safe to enable only because pacioli_guard 0.5.0 closed its matching residual (scope.body_scoped_target parses that body and enforces the credential's per-doctype grant on it, exactly as strictly as the run_method shape). SI/PI/PE are unchanged (regression re-proven live in the same window). Live-proven 2026-07-07: JE submit 0→1 landed verbatim as frappe.client.submit in the bench request log, cancel 1→2, and a raw body-doctype submit naming a never-granted doctype was 403'd before frappe dispatched (PHASE M).

Pacioli is a standalone, pip-installable MCP server that gives an AI agent a governed way to touch ERPNext. Slice-one governs exactly one write — submitting a Sales Invoice — through the full shape PLAN → CONSENT → execute → PROVE, plus a minimal read tier. Everything else is deny-by-default: there is no generic delete, no raw REST passthrough, no confirm-flag the agent can set on its own call. It is not a bench app — that's pacioli_guard, 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_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 → methodsUser.get_roles. Honest caveat: frappe's get_roles honors a ?uid=<user> param with no permission check, so this grant also lets the credential read any user's roles (read-only recon, not escalation); doctor itself never sends uid. Ignoring uid bench-side would be a pacioli_guard change — a separate hardening increment.

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

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

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

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

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

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

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

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

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

Operator walkthrough — the actual tool + CLI names

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

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

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

  1. 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:

  1. 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 mintcancel_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:

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

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

Supported doctypes

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

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

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

Two allowlists, doing different jobs — belt-and-suspenders, not redundant:

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

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

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

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

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

Two Payment-Entry-specific disclosures, both advisory (never a gate), both read from the draft's own cached references child rows (no extra bench call):

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

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

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

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

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

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

Two more advisory disclosures (never a gate):

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

Honest scope

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

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

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

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

Configuration

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

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

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

[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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pacioli-0.30.2.tar.gz (330.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pacioli-0.30.2-py3-none-any.whl (286.0 kB view details)

Uploaded Python 3

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

Hashes for pacioli-0.30.2.tar.gz
Algorithm Hash digest
SHA256 65d210ac227a317346f2a8c71cd49de015b624670ba568110e52163f6eceac78
MD5 02e10e739b30e6f04bbe2c90c56d536d
BLAKE2b-256 6ca7f21e322ff7163b5a4bd1ffb2dc81c84938a95a10705017cfde03d7878bc0

See more details on using hashes here.

Provenance

The following attestation bundles were made for pacioli-0.30.2.tar.gz:

Publisher: release-pypi.yml on john-broadway/pacioli

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for pacioli-0.30.2-py3-none-any.whl
Algorithm Hash digest
SHA256 7af9a9fdc985a2e3393bd8f34ff588f83b81c7b5311d4d49bfb3f971f748d02d
MD5 70a763a5e612c9c3684d3fa467b9fdd9
BLAKE2b-256 cd8554a3a1ba5c9393a89dccfeb1ec06f8b707169b56cf3ca33ab79267a7c708

See more details on using hashes here.

Provenance

The following attestation bundles were made for pacioli-0.30.2-py3-none-any.whl:

Publisher: release-pypi.yml on john-broadway/pacioli

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page