From 8ccfd8cd5720cfd183cc998507209a96e926fe07 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 06:18:24 +0100 Subject: [PATCH 1/2] feat(common): add ErrRefusedBeforePurchase A sentinel for a PurchaseCommitment refused before the call sent any purchase request. It lands in pkg first so the standalone provider modules can pin a pkg commit on main before adopting it (the approved-upfront guards, #672). Refs LeanerCloud/cloud-commitments-mcp#124 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ pkg/common/types.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6cae44..d0631603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- **`common.ErrRefusedBeforePurchase`:** a sentinel for a purchase refused before + the call sent any purchase request, detectable with `errors.Is`. - **Azure `NewPartialSubscriptionFailure` constructor:** builds a `PartialSubscriptionFailureError`, optionally with skip reasons readable through `Skips()`, so code outside the module can construct one, for example diff --git a/pkg/common/types.go b/pkg/common/types.go index 715c1c49..5d5b7158 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -34,6 +34,16 @@ var ErrCurrencyNotAccepted = errors.New("currency not accepted") // contract until the platform persists a typed flag (platform #778). var ErrOutcomeUnknown = errors.New("purchase outcome unknown") +// ErrRefusedBeforePurchase wraps any error from the approved-upfront check, +// which runs before the purchase request is built or sent. Its presence is +// sufficient to know that this call sent no purchase request, but its absence +// proves nothing: other refusals do not carry it. It describes only the call +// that returned it and says nothing about earlier calls with the same +// idempotency token. A wrapper that combines several purchase calls must not +// propagate it unless none of them sent a request. Detect it with +// errors.Is(err, ErrRefusedBeforePurchase). +var ErrRefusedBeforePurchase = errors.New("refused before purchase") + // ProviderType identifies the cloud provider. type ProviderType string From a37da6c40862358f3fa7a7c395cf56c61cf5ceb3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 06:53:17 +0100 Subject: [PATCH 2/2] test(common): pin ErrRefusedBeforePurchase identity and text Assert it wraps, is distinct from ErrOutcomeUnknown, ErrCurrencyNotAccepted and ErrCommitmentPurchaseNotSupported in both directions, and keeps its text. Aliasing it to ErrOutcomeUnknown would let a consumer release spend on a possibly-made purchase; that mutation now fails the test. Co-Authored-By: Claude Opus 5.5 --- pkg/common/types_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/common/types_test.go b/pkg/common/types_test.go index 7e496064..66246d1b 100644 --- a/pkg/common/types_test.go +++ b/pkg/common/types_test.go @@ -626,3 +626,16 @@ func TestErrCurrencyNotAccepted(t *testing.T) { assert.ErrorIs(t, wrapped, ErrCurrencyNotAccepted) assert.NotErrorIs(t, errors.New("currency not accepted"), ErrCurrencyNotAccepted) } + +func TestErrRefusedBeforePurchase(t *testing.T) { + wrapped := fmt.Errorf("%w: %w", ErrRefusedBeforePurchase, errors.New("upfront 1200 exceeds approved 1000")) + assert.ErrorIs(t, wrapped, ErrRefusedBeforePurchase) + assert.NotErrorIs(t, errors.New("refused before purchase"), ErrRefusedBeforePurchase) + // Consumers release spend on this sentinel, so it must never match a sibling + // that marks a purchase as possibly made (ErrOutcomeUnknown). + for _, other := range []error{ErrOutcomeUnknown, ErrCurrencyNotAccepted, ErrCommitmentPurchaseNotSupported} { + assert.NotErrorIs(t, ErrRefusedBeforePurchase, other) + assert.NotErrorIs(t, other, ErrRefusedBeforePurchase) + } + assert.Equal(t, "refused before purchase", ErrRefusedBeforePurchase.Error()) +}