Skip to content

fix(policy): a budget declaration must not switch off max_daily_budget_increase_pct#418

Merged
hyoshi merged 3 commits into
mainfrom
fix/declared-budget-current-fallback
Jul 14, 2026
Merged

fix(policy): a budget declaration must not switch off max_daily_budget_increase_pct#418
hyoshi merged 3 commits into
mainfrom
fix/declared-budget-current-fallback

Conversation

@hyoshi

@hyoshi hyoshi commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

The declaration seam from #414 / #416 replaces the built-in key scan for every channel, including current (_budget_inputs, strategy_gate.py). current_key defaults to None, so for any tool that declares its budget the current budget resolves to None, and this branch of evaluate_guardrails can never fire:

if pct_cap is not None and current is not None and current > 0:

A plugin that declares the expected, documented shape — just daily — therefore has max_daily_budget_increase_pct silently 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 to StrategyPolicyGate gets 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 current does not work around it. unit is 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 declares current divides the baseline by 1e6:

Guardrails(max_daily_budget_increase_pct=20)
{"daily_budget_micros": 15_000_000_000, "current_daily_budget": 10_000}   # ¥10,000 → ¥15,000, +50%

daily only        → allowed=True                                    # cap silently dead
daily + current   → "raises spend 149999900% (0 → 15,000)"          # baseline destroyed

Fix

current falls back to the built-in scan unless the plugin declares it.

The distinction is principled, not a special case: daily / lifetime are budgets the tool proposes, so the plugin's vocabulary is authoritative and replacing the scan wholesale is right (an unrelated field spelled amount must 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 declare current, 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 micros daily declaration + current_daily_budget → the +50% raise is refused (was allowed).
  • test_the_fallback_current_is_currency_not_micros — the reason reads 10,000 → 15,000, not 0 → 15,000.
  • test_a_declared_current_key_still_wins — an explicit current declaration 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.md and the BudgetDeclaration docstring updated: they both stated the "replaces every channel" rule that this changes.

Found by

Adopting the seam in a provider plugin: declaring daily made the plugin's own percentage-cap regression test start passing calls it used to refuse.

hyoshi added 2 commits July 14, 2026 18:19
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
hyoshi force-pushed the fix/declared-budget-current-fallback branch from ef5910b to a3308e9 Compare July 14, 2026 10:01
@hyoshi
hyoshi merged commit 79e4d6a into main Jul 14, 2026
9 checks passed
@hyoshi
hyoshi deleted the fix/declared-budget-current-fallback branch July 14, 2026 10:22
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant