Skip to content

feat(payments): answer priced invocations in explicit-gating sessions with payment-required errors - #112

Draft
harsh04044 wants to merge 4 commits into
ContextVM:mainfrom
harsh04044:feat/cep8-explicit-gating
Draft

feat(payments): answer priced invocations in explicit-gating sessions with payment-required errors#112
harsh04044 wants to merge 4 commits into
ContextVM:mainfrom
harsh04044:feat/cep8-explicit-gating

Conversation

@harsh04044

@harsh04044 harsh04044 commented Aug 23, 2026

Copy link
Copy Markdown

Part of #100

Stacked on #111 (the transparent middleware); diff base d8eae4a, three commits. Retargets or rebases once #111 merges, same as that PR did over its own open parents.

The second lifecycle where money moves: in an explicit_gating session a priced invocation is answered immediately and dropped instead of held. First sighting gets -32042 Payment Required with exactly one payment option through the targeted sender; a detached task verifies the payment and banks a single-use grant keyed by canonical invocation identity; a repeat during verification gets -32043 Payment Pending; a later retry with the same method and params claims the grant atomically and forwards the client's original message untouched, _meta included. A pricing-callback rejection answers -32000 with no data key; a waiver forwards free. No registration and no client retry loop yet: those are the next two PRs.

What's here

  • create_explicit_gating_middleware + ExplicitGatingMiddlewareParams (store injected, options shared with the transparent middleware), with 29 in-file tests driving the real seam chain
  • AuthorizationStore::claim_or_set_pending + ClaimOrPending: the grant check-and-pop and the pending check-and-set in one critical section; the six shipped ops and their tests byte-identical
  • The seam's drop-cleanup now releases the CEP-19 wrap-kind entry a dropped request reserved, guarded on its own route pop. The leak ran until session cleanup on the primary path and forever on the CEP-22 re-inject path
  • 9 e2e tests over MockRelayPool (stateless first exchange pinned tag-for-tag with the original inner id, encrypted cycle, oversized re-inject, shutdown mid-verify, both middlewares registered partitioning by mode) and 4 conformance tests pinning the full error objects through the middleware's own builders
  • One [[test]] Cargo entry, per-PR CHANGELOG. No new dependency, no FFI change. 592 insertions across 9 tracked files plus the two new files

Note

ts runs claim() then trySetPending() back to back and gets the pair atomic free from run-to-completion. rs cannot: the detached verify grants from another OS thread, and the literal two-call port has a window where a settlement pops the pending between the calls, so the server mints a second invoice while a paid grant sits unclaimed. The composed op removes that interleaving; every observable outcome is one ts also produces. The race is measured, not argued: a two-thread barrier probe trips the two-call port roughly ten times per 100k contended rounds and the composed op never.

Things you'll probably ask about

  • Why is the error id the client's inner request id when ts sends the event id? ts rewrites the inner id before its middleware runs and publishes the object as-is; the spec's examples show the request's own id, which is what rs emits. Interop holds because the ts client correlates by e tag and restores the id before reading it. Filed upstream: ts's bytes break any client that correlates errors by JSON-RPC id.
  • Why is a ttl: 0 option treated as absent for the grant horizon? ts mints a grant that expires at birth while the invoice stays payable. ts's own verification-timeout rule already reads ttl <= 0 as absent; rs applies that sibling rule to the grant. Also filed upstream. The offer still advertises the option's ttl verbatim.
  • Why do the publish-failure arms differ per error? A failed -32042 clears pending and spawns no verify, so nothing blocks a retry nothing was offered for; a failed -32043 leaves pending alone, since it belongs to the live original cycle; the -32000 arm cleared before sending. Mirrors ts's try/catch boundaries, and unifying them breaks one direction or the other; each arm has a watched-to-die mutation.
  • Why guard the wrap-kind release on the route pop? An unconditional removal races send_response's pop-then-read on the transparent duplicate-drop path and degrades response mirroring on a paid response. A returned route proves no responder owns the event; every returned-nothing case has its own reclaimer.

Heads up

Verification failure, timeout and pending loss are silent; the next matching invocation gets a fresh offer with a new invoice. A verbatim replay is indistinguishable from a retry by design and can burn a paid grant inside its window. Paying two live offers for one identity leaves one grant (a re-grant overwrites); upstream question. A failed first-offer publish costs the session its one-shot discovery and disclosure latches (sender contract). The store is in-memory and single-process.

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