Repository navigation
Releases: john-broadway/pacioli
Release list
guard-v0.17.0: the floor sees a delete
MINOR. One new gate, one new act, and an upgrade step that fails OPEN if skipped.
doc_events["*"]["on_trash"]->pacioli_guard.act.on_trash. For a consent-gated seat,
deleting a document now needs adeletemarker, minted by a different hand, bound to that
document, single-use. Found on the lab on 2026-10-04 (the frappectl walk, row 36): a gated seat
deleted a draft Sales Invoice over an OAuth bearer and nothing refused it. The documented scope
was docstatus 1 and 2, so nothing said was false; but a seat that must ask before it posts could
erase a draft, and no human saw it. frappe fireson_trashfromdelete_docafter its own
permission check and before its link check and the row removal (model/delete_doc.py:164-171),
so the gate sees REST v1 and v2DELETE,frappe.client.delete, the desk's bulk delete,
Document.delete, background jobs and the console, and it answers before frappe's
LinkExistsErrordoes. Driven on the lab 2026-10-05 (three passes, before and after, 0
fixture rows): on 0.16.0 the gated seat deleted a draft through all seven non-api-key doors, a
submit marker in the header notwithstanding; on 0.17.0 six of them refused with the consent
reason and the seventh, the desk's bulk delete, held the document and reported only a count (that
door swallows the reason by design; the same door deleted for an ungated human). Adelete
marker spent and the draft went, asubmitmarker was refused by act, and a marker spent on a
delete that frappe then refused (a cancelled invoice, held by its own link rule for a human too)
readburned=0after the rollback.- What rides. A delete nested under a governed CANCEL (ERPNext's cancel path deletes the Asset,
Asset Movement, linked Stock Entry and gain/loss journal a posting created), and a document the
act itself created. A pre-existing document deleted under a governed SUBMIT does not ride, same
as a cancel under a submit. Nothing rides an enclosing DELETE: frappe runs the document's own
on_trashbefore any app's handler, so a parent's cascaded deletes are judged before the parent
is, each on its own marker. Named costs, one marker per document in one header:
accounts_controller.on_trash(:490, Serial and Batch Bundles of an invoice),
pick_list.on_trash(:394),item.on_trash(:626, variants), and two save-time deletes
that make a gated seat's draft save of such a document a gated act:
accounts_controller.validate(:254->remove_bundle_for_non_stock_invoices) and
stock_reconciliation.get_bundle_for_specific_serial_batch(:345). All fail closed. CONSENT_ACTSand thePacioli Consent Marker.ref_actionSelect gaindelete;
mint_consent_marker(..., ref_action="delete")mints one.discardremains outside, by design
(see the README).- The refusal text names the residual of the gate that refused: for a delete that is
ignore_on_trash(frappe's installer is its only caller in frappe 16) and raw
frappe.db.delete;flags.ignore_validateis not on the delete path. consent_status.gate_registeredrequireson_trashtoo;SECURITY.mdhad said it checked two
handlers, which stopped being true at 0.14.0.- 🔴 Upgrade:
bench migrateANDbench clear-cache, and this time the hole is fail-OPEN.
Migrate syncs the Select; skipped, minting a delete marker is refused by frappe's own Select
validation, fail closed. The hook registry is the other half: frappe servesdoc_eventsfrom
redis'sapp_hooks(frappe/__init__.py:1006), which a worker restart does not rebuild, so an
in-placepip install+ restart with neither step leaveson_trashout of the registry and
deletes ungated, exactly as before the upgrade, the first upgrade failure this app has that does
not refuse.bench migrateitself callsfrappe.clear_cache()first (frappe/migrate.py:88), so
either step closes it; run both, in that order, as documented since 0.13.0. The lab could not
show the stale shape: there a bare pip install + restart already registered the gate, because its
bench unit restarts redis with the workers. The estate has shown it, on 2026-09-02, when a
recreatedbackendran new code against an oldredis-cache.consent_status.gate_registered: falseis the receipt;
read it after every upgrade.
Where to read more
- guard/README.md - the hooks, the three acts, and what walks around them
- SECURITY.md - the upgrade steps and the one that fails open if both are skipped
- guard/CHANGELOG.md - the full history
- README.md - the two artifacts and the one law
Install: pip install pacioli-guard (Python 3.12+), then bench install-app pacioli_guard.
Upgrade: pip install --upgrade pacioli-guard, then bench --site <site> migrate, then bench --site <site> clear-cache, then restart the bench. Read consent_status.gate_registered afterwards; false means the delete gate is not loaded. Mint a delete marker with bench --site <site> execute pacioli_guard.mint.mint_consent_marker --kwargs '{"ref_doctype": ..., "ref_docname": ..., "ref_action": "delete"}'.
v0.40.1: doctor reads roles from the floor
PATCH. One behaviour fix in doctor, found by the first real customer build. frappe
version-16 moved to 16.33.0 on 2026-09-01 and removed the whitelisted
frappe.core.doctype.user.user.get_roles. The doctor's roles probe (and the belt-exemptions
probe, which reads the same list) reached it through the v2 doctype route User/get_roles;
on a fresh v16 that answers HTTP 417, the seat's roles cannot be read, least-privilege cannot be
certified, and the doctor refuses every install, fail-closed. The lab (16.25) and the 07-17 live
books (16.27) still have the function, which is why no rehearsal saw it.
- Both probes now share one reader with two sources, in order:
pacioli_guard.api.my_roles
(pacioli-guard >= 0.16.0: the floor reports the calling seat's own roles, argument-free, so a
uidcannot point it at another user) and, when the floor is older or the seat lacks that
grant, frappe'sUser/get_rolesas before. The finding carries BOTH answers (my_roles: HTTP n; User/get_roles: HTTP m), so a floor that was upgraded but not granted is named as such, and the
16.33 shape (417 namingget_roles) says its cure out loud: upgrade the guard and grant
pacioli_guard.api.my_roles, or on an older frappe grantUser.get_roles. deploy/scope-methods.listgrants the new method and no longer grantsUser.get_roles(the
?uid=enumeration residual leaves with it on every seat built from the road; an existing seat
keeps its old grant until its scope is re-applied). govern.sh skips a stage whose mark exists,
so an existing install re-applies the scope by removing/root/.pacioli-deploy-marks/g4-scope
and re-running govern with the new list; the guard wheel moves the same way (g1-guard).- Tests: the floor answers first with one call; an older floor falls back with two; a
spine-voiding role from the floor still fails; the exact 16.33 shape names the upgrade; a 417
of another shape gets no hint. Nothing else in the broker changes.
The road (deploy/), folded on the same customer build
Not part of the pacioli wheel: the deploy road is published in this tree and run by hand on a
fresh host. Three adversarial rounds on the 2026-09-07 build changed it, and one finding is a
disclosure for anyone who built a host from the road as published since 2026-07-17:
provision.shwrote the DB root and Administrator passwords into a step file at mode 644,
under a home it makeso+xfor nginx, sowww-dataor any local account on that host could
read both. Its header claimed root-only 600. The step files and.frappe_envare now 600,
frappe-owned. On a host built before this release:chmod 600 /home/frappe/s*_*.sh /home/frappe/.frappe_env, then rotate both passwords. Not a defect in either package, and
nothing a network caller could reach; recorded in SECURITY.md all the same.- Every stage body now runs as plain python, exit-coded.
bench consoleis IPython: it swallows
a failure and exits 0, so a stage could mark itself done having created nothing, and it journals
every input line, passwords included, tologs/ipython.logand the IPython history under that
same home.provision.sh's time-zone step moved the same way, with a positive readback. - The first-custom-row trap applies to the seat's read doctypes too (Company, GL Entry, Accounts
Settings, Workflow, ...): g3 inserted the seat's read row bare, frappe dropped each doctype's
standard permission set, and every human role lost those doctypes. Invisible while every human
on every build was Administrator. g3 now materializes the standard rows first and reads back
that a human role still reads each. A host built before this release carries the latent, and
govern skips a stage whose mark exists, so it never re-runs g3 on its own: either run
reset_permson each of the seat's read doctypes and re-insert the seat's read row, or remove
/root/.pacioli-deploy-marks/g3-seatand rerun govern, which also regenerates the seat's
secret, so carry it to the broker again. - Two stages the road never had.
g2bcreates the fiscal year fromFISCAL_YEAR_START(a
wizard-less install ships none, no voucher posts without one, and the 07-17 live build made it
by hand), on the site's date rather than the container's clock.g6creates desk logins from
DESK_USERS: passwords generated on-target into a 600 file, never echoed, each login proven by
check_passwordand a read of Company. Two humans need the approver role or self-approval OFF
strands every human-drafted invoice; g6 warns below two. - A "set it and rerun" branch (g6, perimeter p2) no longer marks its stage done, so the rerun it
promises actually runs.
Also in the tree since 0.40.0: ruff.toml targets py312 (both floors are 3.12; its comment still
said 3.10/3.11), release_leak_audit.py build-tree works at a non-HEAD ref, the CI actions are
pinned at codeql-action v4.38.0 and setup-uv v10.1.0, and the README opens with a badge row built
the same way as proximo's.
Where to read more
- SECURITY.md - the supported-versions table, 0.40.1 and 0.16.0 included, and the deploy-road note
- README.md - the two artifacts and the one law
- broker/CHANGELOG.md - the full history
- broker/README.md - honest scope, and the doctor's roles note
- deploy/DEPLOY.md - the road, with the fiscal-year and desk-login stages
Install: pip install pacioli (broker, Python 3.12+). Pair it with pip install pacioli-guard 0.16.0 so the doctor can read the seat's roles on frappe 16.33 and later.
guard-v0.16.0: the floor reports the seat's own roles
MINOR. One new read-only endpoint, on the safe list, nothing else changes. frappe 16.33.0
(version-16, 2026-09-01) removed the whitelisted User.get_roles that pacioli doctor used to
read a seat's roles; on a fresh v16 the doctor could not certify any seat and refused every
install. Found by the first real customer build on 2026-09-08.
pacioli_guard.api.my_roles: reportsfrappe.session.user's roles (sorted, stripped,
blanks dropped) and nothing about anyone else. It takes no arguments (auidin the query
is never read), by the same rule asconsent_status. That retires the residual the old
User.get_rolesgrant carried (that function honoured?uid=with no permission check) on
every seat whose scope drops that grant; the road's list drops it from this release on, an
existing seat keeps it until its scope is re-applied.- On
SAFE_METHODS, so a plain methods grantpacioli_guard.api.my_rolesadmits the bare
route. The list grants nothing by itself: without the row the call is still refused
(deny-unknown), exactly likeconsent_status. The road'sdeploy/scope-methods.listcarries
the row from this release on. - Tests pin: session user only, sorted/stripped, a parameterless signature, grantable by config
and still refused ungranted.
Where to read more
- SECURITY.md - the supported-versions table, 0.16.0 and 0.40.1 included
- guard/CHANGELOG.md - the full history
- guard/README.md - the two hooks, two altitudes, and
my_roles - README.md - the two artifacts and the one law
Install: pip install pacioli-guard (Python 3.12+), then bench install-app pacioli_guard;
upgrade = pip install --upgrade, bench migrate, bench clear-cache, then restart the bench so fresh workers read the hooks. Grant pacioli_guard.api.my_roles to the seat (the road's deploy/scope-methods.list carries the row).
v0.40.0: the floor rises to python 3.12
MINOR. One compatibility change and no behaviour change. requires-python is >=3.12
(was >=3.11). An adopter on 3.11 stays on 0.39.1; on 3.12 and 3.13 nothing that worked
stops working. Inside pacioli/ the only diffs are the version string and one comment. What
else ships is the rail around the artifact, and the first proof of the floor a stranger can run.
The floor
requires-python >= 3.12for the broker; the guard moves the same day (its own 0.15.0
entry,>= 3.12from>= 3.10). The public CI matrix mirrors the declared support and
tests exactly 3.12 and 3.13. A matrix that tests a version the metadata does not claim is a
claim the metadata is not making.- The four required checks named for 3.10 and 3.11 retire from the public branch protection
with this release (16 to 12). A required check the tree's own workflows no longer produce
would hold main forever, so it is retired before the release lane runs, never lifted around.
The demo, first release to carry it
scripts/demo/the_floor.pyanddocs/demo/:pip install pacioli pacioli-guard, no
ERPNext, no network. The floor decides the same submit call two ways on one key (unscoped,
allowed; floor-scoped, refused), then shows the scoped seat is not a blanket no: a granted
read on it still passes. The record seals three receipts, then catches a tampered row at its
index, and a truncated tail once the head is pinned off the books. Every decision and every
seal is shipped code, no mock. On the public tree since 2026-08-25 and linked from the README.
The release rails, most of them fixed on their own stumble
verify-pypiwaits for the index before judging the artifact: 0.39.1's verify leg went red
on "no version of pacioli[server]==0.39.1" while the version was live minutes later. It now runs
only for broker (v*) tags; on aguard-v*release it had handed the smoke a literal
guard-v0.14.0.codeql.ymltakesworkflow_dispatch, so it can be run besideci.ymlon a staged sha.- The public commit subject carries the reason. The last six releases, guard 0.14.0 + broker
0.37.0 through 0.39.1, shipped it bare ("release: broker X.Y.Z") with the reason only in the
body, and the subject is the only text GitHub shows beside files and in the commit list. It is read from the changelog
heading's own title; a heading with no title, or a subject past 72 characters, refuses at
release time. A release that ships both halves names both versions in one subject. - The release recipe follows the lane branch protection can live with: the curated sha is
staged on a branch, a pull request attaches the required checks to that exact sha, main
fast-forwards on their strength, the staging branch is deleted. Protection is never lifted. github/codeql-actionpinned to v4.37.9 by commit sha (dependabot #8, folded on the
internal line first; a curated mirror cannot merge a bot's pull request).
Where to read more
- SECURITY.md - the supported-versions table, 0.40.0 and 0.15.0 included
- README.md - the two artifacts and the one law
- broker/CHANGELOG.md - the full history
- broker/README.md - honest scope: what ships and what does not
- docs/demo/ - the floor, runnable with no ERPNext
Install: pip install pacioli (broker, Python 3.12+) / pip install pacioli-guard (floor)
guard-v0.15.0: the floor rises to python 3.12
MINOR. One compatibility change, one build bound, no behaviour change. Inside
pacioli_guard/ the only diffs are the version string and one test comment.
requires-python >= 3.12(was>= 3.10). A site on 3.10 or 3.11 stays on 0.14.0; on 3.12
and 3.13 nothing that worked stops working, and the public CI matrix tests exactly those two.[build-system] requires = ["setuptools>=77.0,<85"]: the upper bound is new. An sdist build
is adopter-facing too, and that line had drifted seven majors past its floor unnoticed.
uv.lockre-locked for the new floor.
Where to read more
- SECURITY.md - the supported-versions table, 0.15.0 and 0.40.0 included
- guard/CHANGELOG.md - the full history
- guard/README.md - the two hooks, two altitudes, and the residual
- README.md - the two artifacts and the one law
Install: pip install pacioli-guard (Python 3.12+), then bench install-app pacioli_guard;
upgrade = bench migrate and bench clear-cache, in that order.
v0.39.1: the smoke gate stops trusting its own claims
PATCH. No runtime change: the only diff inside the installed package is the version string.
What ships is the prove-it-real rail around the artifact, and the release process itself — both
of which failed adversarial review in ways worth reading about.
The [a2a] extra finally has its own gate leg
install_smoke.shgrows a third venv installingpacioli[a2a]ALONE (mcp must be absent —
through 0.37.1 this extra alone built an import error instead of a door, hidden because CI
installs.[server,a2a]together). Until now that leg was run by hand when someone
remembered. Manual is not a gate;release.shgates on this script and the daily
pypi-smokeworkflow runs it against the published artifact.door_socket_check.pydrives the a2a door over real TCP: the agent card over GET (parsed
JSON — name, installed-version match, security honestly advertised or honestly absent), and
a realSendMessagecarrying{"tool": "prove_verify"}that must come back a COMPLETED
task with a spine verdict. The wire body and theA2A-Versionheader are built by the
installed SDK's own serializer, never this file's guess.- Two adversarial lenses broke the first draft of this leg, and both fixes grew teeth rather
than softer words. A gutted executor had passed the whole smoke (the probe method answered
the same -32601 healthy or dead) — the dead-executor mutant now reds with
TASK_STATE_FAILEDin the refusal. And a bearer gate scoped to POST-only had passed every
request the leg sent — the token leg now sends a GET to the RPC path and demands the gate's
own 401 (that mutant answers 405 there, and reds).
The release process, carried both directions
- Public main now moves only on a PROVEN tree: the curated release commit is minted fresh, so
its push used to be CI's first look — 0.39.0 put a red X on public main exactly that way.
The recipe preflights the same tree on a throwaway ref before main moves, and branch
protection now requires the CI checks themselves. - A release needs a descriptive title and real notes (v0.39.0 first shipped as a bare name
with a 90-byte body; since retitled), and the recipe now says so where a release follows it.
Where to read more
- SECURITY.md - the version table, including what 0.39.1 is and is not
- README.md - the two artifacts and the one law
- broker/CHANGELOG.md - the full history
- broker/README.md - honest scope: what ships and what does not
Install: pip install pacioli (broker) / pip install pacioli-guard (floor)
v0.39.0: two more doors, and the socket that outranks the bind
MINOR. New capability, nothing removed, no enforcement path changed. Two doors join the spine
(REST, and the CLI's last direct client), every network door gains a request-time socket refusal,
and the dependency metadata stops lying in both directions.
The through-line, if you read only one paragraph: a bind string is a claim an operator typed
and a socket is a fact. Three doors were deciding on the claim. The REST door learned the
difference the hard way in 0.38's cycle; this release carries that lesson to the other two, and
one of them was demonstrably answering unauthenticated requests from a non-local socket.
The fourth door: REST (2026-08-17)
pacioli serve --rest— the governed VERB set over HTTP: 13 routes covering 163 tools
({doctype}is a path parameter), extra[rest]= uvicorn + anyio only (no mcp, no web
framework). A door admits, it never decides: every route dispatches through the same spine as
stdio/HTTP/A2A.- No consent endpoint, by design, pinned by a test: a consent marker is minted by a
DIFFERENT principal than the credential it authorises; a REST mint route would let the
broker's own key authorise itself. - Hardened across two adversarial lens rounds plus a fix-review round: direct-call refusal
decided onscope["server"](the socket, not the bind string — a forgedHostheader never
reaches dispatch unauthenticated), GET request bodies bounded in time as well as bytes
(unauthenticated slow-read DoS closed), duplicateAuthorizationheaders refused,
guard_asgiwrapped OUTSIDE the perimeter so a refusal is instant rather than after a full
body drain, socket-address classificationipaddress-based and Unix-socket-aware. - The install smoke gets a real-socket REST leg (
scripts/door_socket_check.pycounterpart),
andinstall_smoke.shnow runs insiderelease.sh's gate.
The fifth reroute: the CLI is a door (2026-08-18)
close --reconcile's three audit reads route through the spine. They were the package's
one remaining directErpnextClientconstruction outside dispatch. Three operator tools
(sweep_gl_entries,get_accounts_settings,get_reposts) now live in the dispatch table —
absent from every served/advertised surface, and refused by dispatch itself on every agent
door: only a broker assembled with the CLI door's ownviastamp reaches them (deny-biased;
an undeclared broker is refused too). The 265-tool catalog is unchanged — these are reads,
and the catalog's sentence ("the full submittable transaction surface") stays true.- Close output is byte-identical on the same inputs; the close suites pass unchanged over the
rerouted path. An AST guard reds if any module outsideerpnext.py/runtime.pyre-adds a
directErpnextClientconstruction (any static import spelling — plain, aliased, or
attribute), guarding against accidental regression. - A garbage
PACIOLI_CASCADE_MAXonclose --reconcilenow refuses cleanly instead of raising
a traceback: the reroute assembles the broker (which parses that var), and the close glue's
contract is that it never raises. runtime.assemblegainstransport=(the client's existing pure testing seam, now reachable
through assembly) and hands the door'sviastamp to the broker as well as its stores.
The A2A door: an empty-string token is no token (2026-08-18)
a2a.build_appnow treatstoken=""as no token, matching the HTTP door's
token in (None, "").serve_a2aalready refuses an empty resolved token, so this only
affects the direct-build_appembedder path, wheretoken=""previously: built a public
door instead of refusing; installed a bearer gate that — because_bearer_okfails closed on
an empty token — refused every request (a silent doorstop, not an open door); and advertised
asecuredagent card while enforcing nothing real. Now: public+empty refuses to build,
loopback+empty serves open (the dev default), and the card is honestly unsecured.
Four direct imports nobody declared, and the gate that could not see them (2026-08-24)
[server]and[a2a]now declare the distributions their own modules import directly, and
[rest]'sanyiogains the major ceiling it shipped without. Four requirements in total:
anyio>=4.5,<5on[server]and[a2a],starlette>=0.49.1,<2andpyjwt>=2.0.0,<3on
[a2a], plus the ceiling on[rest]'s existinganyio.- Why it mattered.
server.pyanda2a.pyimport these at runtime, but nothing here declared
them, so they arrived only as somebody else's transitive:mcpdeclaresanyio>=4.5with no
ceiling, anda2a-sdk[http-server]declaresstarlettewith no bound of any kind, not even a
floor. A breaking major of either would have brokenserve --http,serve --stdioand
serve --a2aon every fresh install, exactly the way mcp 2.0.0 broke 0.30.0 through 0.37.0. - The existing bounds gate was structurally blind to this.
test_dependency_boundsreads
declarations and asks whether each bounds its major; an undeclared dependency has no requirement
string for it to read, so all four were invisible to it while it stayed green. New sibling gate
scripts/tests/test_declared_imports.pyasks the other half of the question: every third-party
module a shipped module imports must be declared by the extra that gates that module. Once a
dependency is declared, the bounds gate polices its ceiling automatically. It also pins the
"pure cores and human CLI are stdlib-only" claim, which was prose inpyproject.tomland
nothing else, and it fails closed on an import it has no mapping for rather than skipping it. starletteis recorded as proven across two majors, measured rather than asserted. It
crossed a real 0.x to 1.x boundary, so a<2ceiling claims a major nobody had run.
pacioli[a2a]was installed ALONE on py3.13 (mcp absent) and the a2a suites ran green at
starlette 0.49.1 and again at 1.6.0, with the 0.x leg mutation-proven exercised: hiding the
installedstarletteturns 27 of those 76 red.scripts/tests/test_dependency_bounds.pynow
pins both directions, the way it already did formcp.- Every FLOOR in this block was re-derived from measurement, and three of them were wrong. A
ceiling and a floor are not the same claim. The ceiling is the safety property: an unbounded
major installs a breaking release silently. A floor set above the evidence is the opposite
failure -- it excludes installs that WORK, and CI never notices, because CI resolves the newest
of everything.starlettewas>=0.49.1, justified by the claim that sse-starlette floors it there anyway.
Two independent lenses refuted that from published metadata (only sse-starlette >= 3.0.4
requires starlette in core deps). It was then set to>=0.48, which read as measurement but
was simply whatever one arbitraryfastapi==0.118.0pin resolved to. Both silently locked out
working installs. Now>=0.20: twelve versions from 0.20.4 to 1.6.0 pass on real resolutions.anyiowas>=4.5, a number inherited from whatmcpDECLARES. But[rest]installs no mcp,
so on the one extra where nothing else enforced it, our inherited number was the binding
constraint. Now>=3.4, measured on[rest]alone.uvicornwas>=0.30with no evidence behind it. Now>=0.20, measured on[rest]alone
with the real-socket leg -- and, the question that actually mattered, with the exposed-socket
guard still refusing a forged Host from a LAN socket, confirmingscope["server"]is reported
correctly that far back. A guard's dependency floor is a security question, not a
functionality one.- The rule is now written into
pyproject.tomland ENFORCED. A floor may only come from a
neighbour that already imposes it, a measured RED, or an argued policy floor (cryptography=50 is the last kind and is exempt).
scripts/tests/test_dependency_bounds.pycarries a
MEASURED_FLOORStable and reds on any floor above it, for every dependency at once. - Lowering anyio's floor made its two-major support an explicit claim rather than an implicit
one: the ceiling rule immediately demanded aSUPPORTED_MAJORSentry, which is exactly what
that table exists to force.
- Adopter impact:
pacioli[server]andpacioli[a2a]now bound the dependency MAJORS they
previously inherited by luck, and no floor excludes a version this project has measured green.
pacioli[a2a]coinstalls with FastAPI again (verified on 0.118.0 and 0.110.0, which resolve to
starlette 0.48.0 and 0.36.3). An unconstrained install is unchanged. No API, tool-surface or
enforcement-path change; the catalog stays at 265.
An empty token is no token, and bind is still not the socket (2026-08-24)
- All three network doors now carry the request-time socket refusal. Only REST had it. The
MCP HTTP door and the A2A door each refuse a non-loopback declaration at build time, but a
bindstring and an advertisedrpc_urlare claims an operator typed; the socket the
connection landed on is a fact. A direct embedder or auvicorn --factorypath can build
loopback-declared and serve on any interface, and the REST door demonstrated in 2026-08-17 that
a forgedHostheader then reaches dispatch unauthenticated. Measured before the fix: the A2A
door answered 200 to an unauthenticated POST arriving on a non-local socket, and the HTTP
door had no check to answer with at all. - An empty-string token is now no token at every runtime site, not just at build. The HTTP
door's_asgi_appinstalled its bearer gate ontoken is not None, and_bearer_okfails
closed on an empty configured token, so an empty token produced a door that refused everyone,
including a caller holding the right token. A silent lock rather than an honest refusal. - **The REST door's two runtime sites disa...
pacioli 0.38.0, the door runs on mcp 1.x and 2.x from one build
Broker 0.38.0, the door runs on mcp 1.x and 2.x from one build
MINOR. New capability, nothing removed, no enforcement path changed. Upgrade if you want to run on
mcp 2.x. If you are on mcp 1.x, no action is needed, this release does not change your install.
What changed
The mcp pin widens from >=1.10,<2 to >=1.10,<3, so a fresh install can resolve either major
and the door builds and starts on both.
Tool registration moved from the SDK's decorator API (@server.list_tools(), @server.call_tool())
to its constructor callback API, which is the shape mcp 2.0.0 kept. Everything else the door touches
is unchanged in 2.0.0: the request/response types, stdio_server, StreamableHTTPSessionManager,
Server.run, and ClientSession are the same objects on both majors. Nothing about a 1.x install
changes, it behaves exactly as it did under the old <2 ceiling.
mcp 2.x does not validate tool arguments
Measured on both majors with an undeclared argument. On mcp 1.29.0 the SDK refuses before the
handler runs (isError=True, the handler never sees the call). On mcp 2.0.0 the SDK's schema
validation is gone entirely, the call reaches the handler (isError=None) carrying the misspelled
argument. On 2.x the broker's own dispatch enforcement is the only refusal, which is not a new
gap: the A2A door and pacioli_call's inner arguments already relied on it and never on the SDK.
The outcome is identical on both majors, refused and never silently dropped. Only the message
differs.
CI
The build matrix now runs mcp-major: ["1", "2"] explicitly and asserts the resolved mcp version
matches the pinned major, so a leg that silently resolved the wrong major fails red instead of
passing green by accident.
Upgrading
pip install --upgrade 'pacioli[server]'If you are already on mcp 1.x, this pin still admits your install and nothing about it changes.
Upgrade only if you want to move to mcp 2.x.
pacioli 0.37.2 — 0.37.1 was half a fix, and its guard passed the bug
Broker 0.37.2 — 0.37.1 was half a fix, and the guard it shipped passed the bug it was named after
PATCH. Packaging only. No enforcement path, no allow/deny decision, and nothing a served call does
changes. This is the version to install.
An independent adversarial review of 0.37.1, three hours after it shipped, found four defects. All
four are in work 0.37.1 introduced or touched. None are in the original code it was fixing.
🔴 pip install 'pacioli[a2a]' on its own could not build the A2A door
a2a.py imports a2a.server.routes.*, which needs starlette and sse-starlette. The
a2a-sdk[signing] extra does not carry them. They were present the whole time only because
[server]'s mcp happens to depend on them. Reproduced against the published 0.37.1 wheel:
$ pip install 'pacioli[a2a]==0.37.1'
ModuleNotFoundError: No module named 'sse_starlette'
It also fails badly rather than cleanly. The availability probe checks import uvicorn and
import a2a, both of which succeed, so serve_a2a walks past its own guard, assembles the broker
against the live registry, and dies on an unhandled traceback instead of the governed refusal it
was written to give. Now a2a-sdk[signing,http-server].
This is the same defect 0.37.1 fixed, in the extra 0.37.1 edited, missed for exactly the same
reason: the verification installed both extras together, so the requirement was satisfied by
accident. If you install one extra on its own, upgrade.
🔴 The mcp floor was wrong, and 0.37.1 never looked at it
0.37.1 capped the ceiling and left >=1.0, which had never been true:
| mcp | serve --http |
door suite |
|---|---|---|
| 1.0.0 – 1.7.1 | cannot start (no mcp.server.streamable_http_manager) |
red |
| 1.8.0 – 1.9.4 | starts | red (SDK has no validate_input) |
| 1.10.0 and up | starts | green |
The floor is now >=1.10, the oldest mcp this package actually tests. A floor is a promise that
everything at or above it works. >=1.0 was just the number nobody had questioned.
uvicorn is now declared by the server extra instead of inherited as an undeclared,
unbounded transitive of mcp, because serve_http imports it directly.
Consequence worth knowing: from mcp 1.10.0 the SDK validates arguments before the handler, so
every version this package now permits does validate. Dispatch still enforces
additionalProperties, but the reason is no longer "the SDK might not check" — it is that the A2A
door, pacioli_call's inner arguments, and dynamic by-name calls never reach the SDK's check.
🔴 cryptography is now capped
0.37.1 left it uncapped and justified that as a sibling project's lesson. That project caps it at
<51 and guards the floor separately, and the rule is: bound the major, then widen promptly when
an advisory lands. Widening on an advisory is a release you cut in an hour. An unbounded major is a
break you cannot see. Now >=50,<51, floor still on PYSEC-2026-3552 and still tested.
[build-system] requires is bounded in both packages too.
🔴 The guard 0.37.1 added would have passed the original bug
scripts/tests/test_dependency_bounds.py asked "<2" in requirement. A substring test.
mcp>=1.0,<2.5 contains <2, so the guard went green on a pin that resolves mcp 2.0.0 and
reproduces the original break byte for byte. Names were also compared case-sensitively, so
Cryptography>=42 slipped the advisory floor; and the generic check asked only whether some
upper bound existed, so a2a-sdk<3 passed while admitting the major it was written to exclude.
Rewritten on packaging to ask the only question that matters: given what this requirement
admits, is the version we know breaks us still allowed?
A guard that answers the easy question is worse than no guard, because it is believed.
Upgrading
pip install --upgrade 'pacioli[server]' # or 'pacioli[a2a]', or bothNothing about a working install changes. If you pin mcp below 1.10, that pin is now excluded, and
it was a version the door could not fully serve on anyway.
pacioli 0.37.1 — the MCP door could not start on a fresh install
⚠️ Superseded by 0.37.2. Install that instead.
An independent review found this release was half a fix: the mcp FLOOR was still wrong,pacioli[a2a]on its own could not build its door, and the dependency-bounds guard added here would have passed the very pin that reproduces this bug. Thecryptographyreasoning below is also superseded (it is now capped at<51).
Broker 0.37.1 — the MCP door was dead on arrival for anyone installing it fresh
PATCH. One dependency bound moved. No behaviour in this package changed, no enforcement path
changed, and nothing a served call does is different.
⚠️ If you installed pacioli[server] on or after 2026-07-28, your door could not start
mcp 2.0.0 was published 2026-07-28 and removed the decorator registration API
(Server.list_tools, Server.call_tool) the MCP door is built on. The server extra declared
mcp>=1.0 with no upper bound, so every fresh install from that date resolved mcp 2.x and the door
raised this before it served a single tool:
AttributeError: 'Server' object has no attribute 'list_tools'
Affected: every published version, 0.30.0 through 0.37.0, on a NEW install only. An environment
already holding an mcp 1.x was never affected and nothing about it degrades. If your door is
running today, this release changes nothing for you except the bound.
This is availability, not authorization. The failure is at handler registration, before the door
accepts anything, so no call was ever mis-handled and no consent marker was spendable under a
half-started door. Consent, document binding and the floor are untouched. Not a vulnerability, no
advisory.
The fix
mcp>=1.0,<2
Majors are now bounded per dependency by the surface actually reached, not by blanket rule:
| Dependency | Bound | Why |
|---|---|---|
mcp |
>=1.0,<2 |
The observed break. Lifts to <3 only after the 2.x port. |
a2a-sdk[signing] |
>=1.1,<2 |
Deep surface (a2a.server.routes.*, a2a.types.a2a_pb2, a2a.utils.signing). Same break shape as mcp. |
uvicorn |
>=0.30,<1 |
One call, but a 0.x project breaks on MINOR, so the cap sits at the 1.0 line. |
cryptography |
>=50, uncapped |
Only serialization and asymmetric.ec are touched, and this floor tracks PYSEC-2026-3552. A major cap here would block the next advisory bump. |
How it stayed invisible
Nothing exercised the pin as a resolved environment. The build venv held mcp 1.28.1, so local runs
were green. CI did resolve mcp 2.0.0 but had no test that started the door. A test added in 0.37.0 is what turned a shipped break into a red build, on the very push that landed it. (Corrected in 0.37.2: this said "the door-startup tests" and "one push after". Both wrong. Those tests use a FakeApp stub and passed under mcp 2.0.0; what went red was a test that constructs a real mcp Server, on the same push that introduced it.)
A lockfile protects the build, not the adopter. A published >= with no upper bound promises that
every future major of a dependency will keep working, and no package can keep that promise.
Still to come
Porting the door to the mcp 2.x MCPServer / add_request_handler shape is separate work. Until it
lands, 2.x is refused outright rather than half-supported.
Verify it yourself
uv pip install --refresh 'pacioli[server]==0.37.1'
python -c "import importlib.metadata as m; print(m.version('mcp'))" # 1.x, not 2.x