fix(policy): a budget declaration must not switch off max_daily_budget_increase_pct#418
Merged
Merged
Conversation
The declaration seam (#414) replaced the built-in key scan for EVERY channel, including `current`. `current_key` defaults to None, so a plugin declaring the documented shape — just `daily` — had `current` resolve to None and `max_daily_budget_increase_pct` silently stop firing for its tools. Declarations are registered process-globally from the entry points, so the plugin's own gate delegating to StrategyPolicyGate got the same answer: no way to opt out. That is the silent underenforcement #414 exists to remove, reintroduced through the seam, landing on the plugins that adopted it. Declaring `current` was not a workaround either: `unit` is one flag covering every declared key, while mureo's `current_daily_budget` convention is currency units — so a micros tool divided the baseline by 1e6 and reported a ¥10,000 → ¥15,000 raise as "149999900% (0 → 15,000)". `current` now falls back to the built-in scan unless declared. The distinction is principled: `daily` / `lifetime` are budgets the TOOL PROPOSES, so the plugin's vocabulary is authoritative there; the current budget is context the CALLER supplies, under mureo's own cross-provider convention. An explicit `current` declaration still wins. Docs + BudgetDeclaration docstring updated — both stated the rule this changes. Claude-Session: https://claude.ai/code/session_01GpqYLYBm3mVpYf4oV9gJ5S
Review follow-ups on the fallback: - Scan only `current_daily_budget`, never the bare `current` alias the built-in scan also accepts. A declaring plugin owns its argument vocabulary, and `current` is a plausible name for something else entirely (an index, a status). Misreading one as the baseline is the dangerous direction: a large stray value yields a small percentage, so it would ALLOW a raise that should have been refused. Built-in tools keep the full scan. - Guard on a falsy `current_key`, not `is None`: `_declared_amount` already treats a blank key as absent, so a hand-built BudgetDeclaration(current_key="") would otherwise skip the fallback and take the pct cap dark again. - evaluate_guardrails' own docstring still claimed the declaration replaces the built-in scan wholesale. Claude-Session: https://claude.ai/code/session_01GpqYLYBm3mVpYf4oV9gJ5S
…n, and fail closed Rebasing onto main (which gained #419) surfaced two problems in the fix. 1. The `current` fallback read the baseline with a bare `float()`, bypassing the `_saturate` + `math.isfinite` choke point #419 put on every budget channel. A `nan` baseline makes `current > 0` False and takes the percentage cap dark; a bare oversized int raises OverflowError into StrategyPolicyGate.evaluate's blanket `except` — an abstain, i.e. an allow. Both are the exact bypasses #419 closed on the built-in path, so the fallback is now held to the same standard. 2. `max_total_daily_budget` was switched off by a declaration the same way `max_daily_budget_increase_pct` was, and worse: `projected_total_daily_budget` has no declaration key at all, so a declaring plugin had NO way to keep the total cap alive. It is caller-supplied context under the same cross-provider convention as `current_daily_budget` — the skills compute and pass it; no tool proposes it — so it is resolved on the declared path too, and fails closed on a non-finite figure. The rule is now one rule: a declaration replaces the built-in scan for the budgets the TOOL PROPOSES (`daily` / `lifetime`); the two figures the CALLER supplies are never replaced. Docstrings, docs/plugin-authoring.md and the CHANGELOG entry all say that, and the docs gained a warning against declaring `current: "current_daily_budget"` with `unit: "micros"` (it would divide the baseline by 1e6). TDD, red first: TestUnreadableFallbackCurrent and TestDeclarationKeepsTheTotalCap in tests/test_plugin_budget_declaration.py. Claude-Session: https://claude.ai/code/session_0115NBUphwqsL1isTV8R7NHg
hyoshi
force-pushed
the
fix/declared-budget-current-fallback
branch
from
July 14, 2026 10:01
ef5910b to
a3308e9
Compare
hyoshi
added a commit
that referenced
this pull request
Jul 14, 2026
…dget (#417) (#421) A declaration (#414) names a TOP-LEVEL argument key. Two ordinary plugin shapes are outside what that can express, as #417 reports from adopting the seam: - The budget is NESTED. A native passthrough tool takes the platform's raw request body, so the budget is at `body.daily_budget`. Declaring `body` does not help: a key holding a dict is unreadable, so the gate fails closed and refuses every native call, the legal ones included. - The budget is DERIVED. A mapper computes `monthly = daily × multiplier` and sends it; the caller never typed it, so no key can name it — and it is real spend, so a cap must reach it. The guide flatly told authors they never need a `mureo.policy_gates` entry for budget enforcement. That is untrue for those shapes, and following it would leave a real-spend surface unenforced. It now says so, and documents the gate to register instead: normalize-and-delegate — project the budget onto the canonical keys the built-in scan already reads, then hand the decision straight back to StrategyPolicyGate. The point of the pattern is that the plugin supplies KEYS, NOT A POLICY: cap comparison, STRATEGY.md parsing, the TTL cache and the fail-closed rules keep a single owner, so upstream changes to guardrail semantics (#414, #418, #419) reach the plugin without it touching anything. Includes a working example (verified against the gate), the entry-point wiring, and the rules such a gate must follow: pure and fast (no network — hence the caps needing a current budget or an account-wide projection are simply out of reach, and should be documented as such rather than fetched); never raise (the ABI is fail-open; a gate that raised deliberately would be an outage dressed up as a guardrail); cover every path to the same spend (or an agent refused on one tool retries through its twin); and do not also declare, since a declaration replaces the built-in scan and would switch off the very keys the gate injects. No code change: the existing PolicyGate seam (#175) is the right extension point for these shapes, and mureo's decision layer stays the single owner of what a guardrail means. Claude-Session: https://claude.ai/code/session_0115NBUphwqsL1isTV8R7NHg
This was referenced Jul 14, 2026
Merged
hyoshi
added a commit
that referenced
this pull request
Jul 14, 2026
Bump 0.10.24 -> 0.10.25 (pyproject, __init__, plugin.json, 51 SKILL.md frontmatters) and cut the CHANGELOG for the two changes merged since 0.10.24. Fixed — a budget declaration no longer switches off max_daily_budget_increase_pct or max_total_daily_budget (#418). The declaration seam (#414) replaced the built-in key scan for EVERY channel, including the two figures the CALLER supplies rather than the tool, so a plugin that declared the documented shape (just `daily`) had both caps silently stop firing for its tools. Neither was work-around-able: declaring `current` on a micros tool divided the baseline by 1e6, and `projected_total_daily_budget` has no declaration key at all. Both channels now keep mureo's own convention keys, held to the same fail-closed standard as every other budget channel (#419). Docs — plugin-authoring.md documents what to do when a declaration cannot reach a plugin's budget (#417). A declaration names a top-level argument key, so it cannot express a budget nested in a raw passthrough body nor one a mapper derives. The guide had told authors they never need a `mureo.policy_gates` entry for budget enforcement, which is untrue for those shapes; it now shows the normalize-and-delegate gate to register instead, and the rules it must follow. Claude-Session: https://claude.ai/code/session_0115NBUphwqsL1isTV8R7NHg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The declaration seam from #414 / #416 replaces the built-in key scan for every channel, including
current(_budget_inputs,strategy_gate.py).current_keydefaults toNone, so for any tool that declares its budget the current budget resolves toNone, and this branch ofevaluate_guardrailscan never fire:A plugin that declares the expected, documented shape — just
daily— therefore hasmax_daily_budget_increase_pctsilently switched off for its tools. Not only on the built-in gate: declarations are registered process-globally at import from the entry points, so a plugin's own gate delegating toStrategyPolicyGategets the same answer. There is no way for the plugin to opt out.That is the same silent underenforcement #414 exists to remove, reintroduced through the seam itself — and it lands on precisely the plugins that did the right thing and adopted it.
Declaring
currentdoes not work around it.unitis one flag covering every declared key, while mureo's own convention (current_daily_budget, passed by the skills — see_mureo-strategy/SKILL.md) is currency units. A micros tool that declarescurrentdivides the baseline by 1e6:Fix
currentfalls back to the built-in scan unless the plugin declares it.The distinction is principled, not a special case:
daily/lifetimeare budgets the tool proposes, so the plugin's vocabulary is authoritative and replacing the scan wholesale is right (an unrelated field spelledamountmust not false-trip a cap). The current budget is not something the tool carries at all — it is context the caller supplies, under mureo's own cross-provider convention. A plugin that genuinely does take the current budget as an argument can still declarecurrent, and that declaration still wins.Tests
TDD, red first, in
tests/test_plugin_budget_declaration.py:test_undeclared_current_falls_back_to_the_built_in_key— a microsdailydeclaration +current_daily_budget→ the +50% raise is refused (was allowed).test_the_fallback_current_is_currency_not_micros— the reason reads10,000 → 15,000, not0 → 15,000.test_a_declared_current_key_still_wins— an explicitcurrentdeclaration is not overridden by the fallback.63 passed across
test_plugin_budget_declaration.py+test_strategy_gate.py; ruff / black / mypy clean.docs/plugin-authoring.mdand theBudgetDeclarationdocstring updated: they both stated the "replaces every channel" rule that this changes.Found by
Adopting the seam in a provider plugin: declaring
dailymade the plugin's own percentage-cap regression test start passing calls it used to refuse.