Skip to content

Releases: john-broadway/pacioli

guard-v0.17.0: the floor sees a delete

Choose a tag to compare

@john-broadway john-broadway released this 06 Oct 00:36

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 a delete marker, 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 fires on_trash from delete_doc after 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 v2 DELETE, frappe.client.delete, the desk's bulk delete,
    Document.delete, background jobs and the console, and it answers before frappe's
    LinkExistsError does. 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). A delete
    marker spent and the draft went, a submit marker 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)
    read burned=0 after 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_trash before 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_ACTS and the Pacioli Consent Marker.ref_action Select gain delete;
    mint_consent_marker(..., ref_action="delete") mints one. discard remains 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_validate is not on the delete path.
  • consent_status.gate_registered requires on_trash too; SECURITY.md had said it checked two
    handlers, which stopped being true at 0.14.0.
  • 🔴 Upgrade: bench migrate AND bench 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 serves doc_events from
    redis's app_hooks (frappe/__init__.py:1006), which a worker restart does not rebuild, so an
    in-place pip install + restart with neither step leaves on_trash out of the registry and
    deletes ungated, exactly as before the upgrade
    , the first upgrade failure this app has that does
    not refuse. bench migrate itself calls frappe.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
    recreated backend ran new code against an old redis-cache. consent_status.gate_registered: false is the receipt;
    read it after every upgrade.

Where to read more

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

Choose a tag to compare

@john-broadway john-broadway released this 25 Sep 05:50

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
    uid cannot point it at another user) and, when the floor is older or the seat lacks that
    grant, frappe's User/get_roles as 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 naming get_roles) says its cure out loud: upgrade the guard and grant
    pacioli_guard.api.my_roles, or on an older frappe grant User.get_roles.
  • deploy/scope-methods.list grants the new method and no longer grants User.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.sh wrote the DB root and Administrator passwords into a step file at mode 644,
    under a home it makes o+x for nginx, so www-data or any local account on that host could
    read both.
    Its header claimed root-only 600. The step files and .frappe_env are 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 console is 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, to logs/ipython.log and 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_perms on each of the seat's read doctypes and re-insert the seat's read row, or remove
    /root/.pacioli-deploy-marks/g3-seat and rerun govern, which also regenerates the seat's
    secret, so carry it to the broker again.
  • Two stages the road never had. g2b creates the fiscal year from FISCAL_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. g6 creates desk logins from
    DESK_USERS: passwords generated on-target into a 600 file, never echoed, each login proven by
    check_password and 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

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

Choose a tag to compare

@john-broadway john-broadway released this 25 Sep 05:50

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: reports frappe.session.user's roles (sorted, stripped,
    blanks dropped) and nothing about anyone else. It takes no arguments (a uid in the query
    is never read), by the same rule as consent_status. That retires the residual the old
    User.get_roles grant 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 grant pacioli_guard.api.my_roles admits the bare
    route. The list grants nothing by itself: without the row the call is still refused
    (deny-unknown), exactly like consent_status. The road's deploy/scope-methods.list carries
    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

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

Choose a tag to compare

@john-broadway john-broadway released this 05 Sep 16:58

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.12 for the broker; the guard moves the same day (its own 0.15.0
    entry, >= 3.12 from >= 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.py and docs/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-pypi waits 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 a guard-v* release it had handed the smoke a literal
    guard-v0.14.0.
  • codeql.yml takes workflow_dispatch, so it can be run beside ci.yml on 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-action pinned 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

Install: pip install pacioli (broker, Python 3.12+) / pip install pacioli-guard (floor)

guard-v0.15.0: the floor rises to python 3.12

Choose a tag to compare

@john-broadway john-broadway released this 05 Sep 16:58

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.lock re-locked for the new floor.

Where to read more

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

Choose a tag to compare

@john-broadway john-broadway released this 24 Aug 16:04

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.sh grows a third venv installing pacioli[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.sh gates on this script and the daily
    pypi-smoke workflow runs it against the published artifact.
  • door_socket_check.py drives 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 real SendMessage carrying {"tool": "prove_verify"} that must come back a COMPLETED
    task with a spine verdict. The wire body and the A2A-Version header 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_FAILED in 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

Install: pip install pacioli (broker) / pip install pacioli-guard (floor)

v0.39.0: two more doors, and the socket that outranks the bind

Choose a tag to compare

@john-broadway john-broadway released this 24 Aug 08:38

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 on scope["server"] (the socket, not the bind string — a forged Host header never
    reaches dispatch unauthenticated), GET request bodies bounded in time as well as bytes
    (unauthenticated slow-read DoS closed), duplicate Authorization headers refused,
    guard_asgi wrapped OUTSIDE the perimeter so a refusal is instant rather than after a full
    body drain, socket-address classification ipaddress-based and Unix-socket-aware.
  • The install smoke gets a real-socket REST leg (scripts/door_socket_check.py counterpart),
    and install_smoke.sh now runs inside release.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 direct ErpnextClient construction 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 own via stamp 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 outside erpnext.py/runtime.py re-adds a
    direct ErpnextClient construction (any static import spelling — plain, aliased, or
    attribute), guarding against accidental regression.
  • A garbage PACIOLI_CASCADE_MAX on close --reconcile now 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.assemble gains transport= (the client's existing pure testing seam, now reachable
    through assembly) and hands the door's via stamp to the broker as well as its stores.

The A2A door: an empty-string token is no token (2026-08-18)

  • a2a.build_app now treats token="" as no token, matching the HTTP door's
    token in (None, "").
    serve_a2a already refuses an empty resolved token, so this only
    affects the direct-build_app embedder path, where token="" previously: built a public
    door instead of refusing; installed a bearer gate that — because _bearer_ok fails closed on
    an empty token — refused every request (a silent doorstop, not an open door); and advertised
    a secured agent 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]'s anyio gains the major ceiling it shipped without. Four requirements in total:
    anyio>=4.5,<5 on [server] and [a2a], starlette>=0.49.1,<2 and pyjwt>=2.0.0,<3 on
    [a2a], plus the ceiling on [rest]'s existing anyio.
  • Why it mattered. server.py and a2a.py import these at runtime, but nothing here declared
    them, so they arrived only as somebody else's transitive: mcp declares anyio>=4.5 with no
    ceiling, and a2a-sdk[http-server] declares starlette with no bound of any kind, not even a
    floor. A breaking major of either would have broken serve --http, serve --stdio and
    serve --a2a on 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_bounds reads
    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.py asks 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 in pyproject.toml and
    nothing else, and it fails closed on an import it has no mapping for rather than skipping it.
  • starlette is recorded as proven across two majors, measured rather than asserted. It
    crossed a real 0.x to 1.x boundary, so a <2 ceiling 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
    installed starlette turns 27 of those 76 red. scripts/tests/test_dependency_bounds.py now
    pins both directions, the way it already did for mcp.
  • 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.
    • starlette was >=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 arbitrary fastapi==0.118.0 pin 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.
    • anyio was >=4.5, a number inherited from what mcp DECLARES. 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.
    • uvicorn was >=0.30 with 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, confirming scope["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.toml and 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.py carries a
      MEASURED_FLOORS table 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 a SUPPORTED_MAJORS entry, which is exactly what
      that table exists to force.
  • Adopter impact: pacioli[server] and pacioli[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
    bind string and an advertised rpc_url are claims an operator typed; the socket the
    connection landed on is a fact. A direct embedder or a uvicorn --factory path can build
    loopback-declared and serve on any interface, and the REST door demonstrated in 2026-08-17 that
    a forged Host header 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_app installed its bearer gate on token is not None, and _bearer_ok fails
    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...
Read more

pacioli 0.38.0, the door runs on mcp 1.x and 2.x from one build

Choose a tag to compare

@john-broadway john-broadway released this 12 Aug 02:43

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

Choose a tag to compare

@john-broadway john-broadway released this 11 Aug 18:20

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 both

Nothing 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

Choose a tag to compare

@john-broadway john-broadway released this 11 Aug 17:08

⚠️ 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. The cryptography reasoning 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