Skip to content

scaffold+broker: fix provisioned-app balance 403, cooldown-gated reads, path drift (review fixes for #73)#92

Open
TeoSlayer wants to merge 3 commits into
mainfrom
fix/balance-method-provisioned
Open

scaffold+broker: fix provisioned-app balance 403, cooldown-gated reads, path drift (review fixes for #73)#92
TeoSlayer wants to merge 3 commits into
mainfrom
fix/balance-method-provisioned

Conversation

@TeoSlayer

Copy link
Copy Markdown
Contributor

Implements the review fixes requested on #73 ("scaffold+broker: dedicated <ns>.balance method for managed budget apps"). #73's head branch lives on a fork, so per review instructions this is a fresh branch/PR off main (rebased) rather than a push to the fork.

Closes/supersedes the balance-method work in #73 — same feature, plus the four fixes below.

Fixes

1. Functional bug — provisioned apps shipped a broken always-403 .balance method.
Scaffold's injection guard if c.Managed() (internal/scaffold/config.go) is true for both auth:"managed" and auth:"provisioned", but the broker only ever serves the canonical /_pilot/balance route for classic managed apps — provisioned apps route through serveProvisioned (internal/broker/identity.go/provision.go), which recognizes only ProvisionSpec.BalancePath (default /_balance) and 403s on anything else. A provisioned app that doesn't hand-author its own .balance (unlike io.pilot.smol, which does) got one auto-injected that always 403s.

Fix: gated the injection to c.Managed() && !c.Provisioned() — scaffold has no visibility into a provisioned app's actual (registry-only) BalancePath, so a provisioned app must author its own .balance pointed at the real path, same as io.pilot.smol's smol.balance -> /_balance.

Test: TestBalanceMethod_NotInjectedForProvisioned (internal/scaffold/zz_balance_method_test.go) — asserts a provisioned spec does NOT get an auto-injected <ns>.balance.

2. Rate-limit / cooldown interaction.
serveCreditBalance seeded via seedCaller -> ProvisionStore.Provision on every hit, including repeats, and MemStore.Provision's mint_cooldown_ms check fires on every repeat touch — so a second .balance call inside the cooldown window 429'd with "re-provision cooldown" instead of returning the balance.

Fix: an already-seeded caller is now answered straight from ProvisionStore.Get (no cooldown check, no LastMint touch, no re-seed); only a caller's genuine first-ever touch goes through the seed path (still enforcing the per-IP grant cap), and a first touch can never itself hit the cooldown. Since the balance routes have no Store.Admit/Quota gate at all, added a light, generous per-(app,caller) token bucket (allowBalanceRead, burst 20 / refill 5/s) so the now-cooldown-exempt route isn't literally unbounded — applied to both serveCreditBalance (classic managed) and serveBalance (provisioned), which had the same "served before any Admit/Quota gate" shape.

Tests: TestBalanceMeta_ReadIgnoresMintCooldown (repeat .balance calls return the balance, not 429, with mint_cooldown_ms: 60000; a real metered call once time actually elapses is still correctly cooldown-gated) and TestBalanceRateLimit_BurstThenSheds (burst always succeeds, a tight loop past it sheds, and it recovers after a refill interval).

3. Duplicated route literal.
Extracted scaffold.BalanceMetaPath + broker.pilotBalancePath ("/_pilot/balance") into one shared constant, internal/pilotpath.Balance (new package, since scaffold and broker don't otherwise import each other). Both constants now derive from it.

Test: TestBalancePath_SharedConstant_NoDrift — asserts broker.pilotBalancePath == pilotpath.Balance, so a future hardcoded-literal regression is caught immediately.

4. Cross-caller isolation.
TestBalanceMeta_CrossCallerIsolation — two distinct signed identities against the same credit app; each .balance call returns only the calling identity's own numbers (seed/spend on one caller never leaks into or is affected by the other's reads).

Verification

  • Rebased onto current main (git merge origin/main) — merged cleanly, no conflicts, no lockfile drift (go.mod/go.sum unchanged, go mod tidy is a no-op).
  • go build ./... clean.
  • go vet ./... clean.
  • go test ./... fully green (all packages).
  • gofmt -l . clean.
  • Sanity-checked both new regression tests actually catch the original bugs by temporarily reverting each fix in isolation and confirming the corresponding test fails, then restored.

Safety

The balance routes remain pure, caller-scoped reads: no Debit/Refund/Settle call was added anywhere on this path, nothing touches the master key, and no upstream/partner forward was introduced. serveCreditBalance reads via ProvisionStore.Get/Credit only.

🤖 Generated with Claude Code

Alexgodoroja and others added 3 commits July 7, 2026 15:20
Managed (credit-metered) apps could meter a per-user $-budget and surface it
on the X-Pilot-Credits-Remaining header, but a keyless adapter only returns the
response body — so an agent could never actually read its balance, and there was
no first-class way to check funds before a spend op.

This wires a dedicated balance method, end to end:

- Broker answers a canonical /_pilot/balance for any credit app from its own
  per-caller ledger — no upstream call, no master key, no debit, never a 402,
  scoped to the caller (the pooled account balance is never disclosed). Reuses
  the existing serveCreditBalance (which also backs the optional creditBalancePath
  partner-endpoint shadow); the canonical path is always available alongside it.
- Scaffolder auto-injects `<ns>.balance` into every `auth: managed` app (no
  submission field), a GET to that path — flowing through registration, the
  manifest `exposes` list, and `<ns>.help` like any authored method.
- scaffold.BalanceMetaPath == broker.pilotBalancePath keeps the two ends in sync.
- Tests: broker (canonical path seeds, reflects spend, free, never forwards) and
  scaffold (injected for managed only, generated + compiles + in the manifest).
- Docs: MANAGED-KEY.md and the publishing playbook document the pattern for all
  broker apps that meter a budget.
…s, path drift

Fixes from PR #73 review:

1. Scaffold's `if c.Managed()` injection guard fired for BOTH auth:managed
   and auth:provisioned (Managed() is true for both), but the broker only
   ever answers the canonical /_pilot/balance route for classic managed
   apps — provisioned apps route through serveProvisioned, which only
   recognizes ProvisionSpec.BalancePath (default /_balance) and 403s on
   anything else. Provisioned apps that don't hand-author their own
   `.balance` method (unlike io.pilot.smol) shipped one that always 403s.
   Gated the injection to `c.Managed() && !c.Provisioned()`.

2. serveCreditBalance seeded via seedCaller -> ProvisionStore.Provision on
   every hit, including repeats, and MemStore.Provision's mint_cooldown_ms
   check fires on every repeat touch — so a second `.balance` call inside
   the cooldown window 429'd with "re-provision cooldown" instead of
   returning the balance. Now an already-seeded caller is answered from
   ProvisionStore.Get (no cooldown check, no LastMint touch); only a
   genuine first-ever touch goes through the seed path, which can never
   itself hit the cooldown. Since the balance routes have no
   Store.Admit/Quota gate, added a light, generous per-(app,caller) token
   bucket (allowBalanceRead, burst 20 / refill 5/s) so the now-cooldown-
   exempt route isn't literally unbounded, applied to both
   serveCreditBalance and the provisioned serveBalance.

3. Extracted the duplicated "/_pilot/balance" literal
   (scaffold.BalanceMetaPath + broker.pilotBalancePath) into one shared
   constant, internal/pilotpath.Balance, so the two can no longer drift.

4. Added a cross-caller isolation test: two distinct signed identities
   against the same app each see only their own balance.

Balance routes remain pure reads: no debit, no forward to upstream, no
master-key path touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

3 participants