Skip to content

fix(core): give back a bounded use whose decision could not be recorded - #104

Merged
DaXu999 merged 4 commits into
mainfrom
fix/bounded-use-release
Nov 8, 2026
Merged

DaXu999 merged 4 commits into
mainfrom
fix/bounded-use-release

Conversation

@DaXu999

@DaXu999 DaXu999 commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

The gap

A maxUses grant is spent when the decision is made, and the decision's authorization.checked is written after. A sink that throws there refuses the call, correctly, and keeps the use. One audit outage exhausts a maxUses: 1 grant whose provider was never entered. ADR 0023 recorded this as a known limit.

Reported and reproduced by @Alfazal007 in #101, which fixed it with a reservation on a replay record, an effect store and an audit outbox, on top of #99 and #100. This PR reaches the same outcome on the usage port alone, and replaces #101.

What changes

Core (c9eb3c6, 4af27b2)

  • GrantUsageStore gains an optional release(namespaceId, grantId): an atomic decrement that stops at zero. InMemoryGrantUsageStore implements it.
  • The kernel calls CapabilityAuthorizer.release(decision) in one place: when the record of a decision is refused, so the call never ran.
  • The authorizer keeps each spent use against the decision that spent it, under the namespace and grant id tryConsume was given. release gives back that use, at most once, and nothing for a decision that spent nothing (a denial, a non-consuming check, an unbounded grant, a decision another authorizer made).
  • It does not look the grant up by id. Nothing makes a grant id unique within an authority, and the first version of this PR did: an unbounded grant's unrecorded decision gave back a use that a bounded grant under the same id had spent on a call that ran, so a maxUses: 1 tool could run twice. 4af27b2 fixes that.
  • ADR 0023's "known limit" paragraph is revised in place, and says why the use is kept against the decision.

Reference host and changelog (44cd1bb)

  • The SQLite usage store implements release as one guarded UPDATE.
  • One changelog entry under Unreleased.

Host-facing docs (d10f49b)

  • docs/host-integration.md: a turn is the one operation the kernel does not run itself, so a host that admits turns without SharedOSExecutor must treat an admitTurn that rejects as admitting nothing and not run the turn. Its use has gone back.
  • The changelog entry no longer says an outage never costs a use: a use whose decision was recorded stays spent, even when a later record fails before anything runs. docs/errors.md notes the give-back beside the outage paragraph.

What it does not do

  • A store without release, or one that throws from it, keeps the use as before. The caller is told of the audit outage either way.
  • Nothing else gives a use back. A call cancelled or failed after its decision was recorded has used the grant.
  • A sink that stores the decision and then throws leaves an allowed, consuming authorization.checked with no operation record after it, for a use that went back. Usage rebuilt from the trail should count operation records, not decisions. ADR 0023 says so.
  • No replay store, effect store, outbox, or mandatory audit configuration. Those are fix(core): make bounded grant effects and audit recoverable #101's larger design and are not taken here.

Verification

  • Rebased on main (b585bbc); the only conflict was CHANGELOG.md. pnpm check exits 0: format, typecheck, 1155 tests, release tests, API reference up to date, conformance manifest unchanged.
  • Kernel tests cover the give-back on a tool call, a resource request, a direct send and a turn admission, each with its port never entered. They also cover nothing given back for a refusal, for a record that fails after the effect, and for an unbounded grant sharing a bounded grant's id.
  • The first kernel test fails on main's kernel: usage reads 1 after the outage and the retry is denied. The duplicate-id test fails on this PR's first version: the tool runs twice on maxUses: 1.
  • Probed beyond the suite, not committed:
    • Every consuming path, and every caller outside the kernel, traced for a call that runs after its decision was refused. None does.
    • 1,800 seeded concurrent runs of 50 to 200 calls, with 20 to 80% of decision records failing. Handler entries never exceeded maxUses, and no count went negative or landed on another grant.
    • The SQLite release across two connections and across worker threads on one file, and against an in-memory database: no change on an empty counter, one use back after a consume, never below zero.

A maxUses grant is spent when the decision is made and the decision's
authorization.checked is written after. A sink that threw there refused the
call and kept the use, so one audit outage exhausted a maxUses: 1 grant
whose provider was never entered. ADR 0023 recorded it as a known limit.

GrantUsageStore gains an optional release, an atomic decrement that stops
at zero, and the kernel calls it when the record of an allowed, consuming
decision is refused. A store without one, or one that throws from it, keeps
the use as before, and the caller is told of the audit outage either way.
Nothing else gives a use back.

The defect was reported in #101, which fixed it with a reservation on a
replay record, an effect store and an audit outbox. This is the same
outcome on the usage port alone. ADR 0023 is revised.
Implements GrantUsageStore.release as one UPDATE guarded on used > 0, and
adds the changelog entry for the fix.
CapabilityAuthorizer.release found the grant to give back by id, and
nothing makes a grant id unique within an authority. With a bounded grant
and an unbounded one under the same id, the unbounded grant's unrecorded
decision gave back the use the bounded one had spent on a call that ran,
so a maxUses: 1 tool could run twice.

The authorizer now keeps each spent use against the decision that spent it,
under the namespace and grant id tryConsume was given, and release takes
that decision. A decision that spent nothing (a denial, a non-consuming
check, an unbounded grant, one another authorizer made) gives nothing
back, and a spent use goes back at most once.

Tests cover the duplicate id, and the give-back on a resource request, a
direct send and a turn admission, with nothing given back for a refusal or
when a record after the decision fails. ADR 0023 says why the use is kept
against the decision, and that an ambiguous write leaves a consuming
authorization.checked with no operation or turn after it.
The kernel stops every other operation whose decision was not recorded,
but a turn is run by the host. A host that admits turns without
SharedOSExecutor and runs one after admitTurn rejected now does so on a
use that went back, so host-integration.md says not to.

The changelog entry no longer says an audit outage never costs a use: a
use whose decision was recorded stays spent even when a later record fails
before anything runs. errors.md notes the give-back beside the outage
paragraph.
@DaXu999
DaXu999 force-pushed the fix/bounded-use-release branch from 87ca15e to d10f49b Compare November 8, 2026 14:09
@DaXu999
DaXu999 merged commit 59fbaf9 into main Nov 8, 2026
2 checks passed
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