From 9d90d1a410d42cec2e26e6edeafbde6cc215a3fa Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Tue, 11 Aug 2026 16:50:00 +0200 Subject: [PATCH 1/6] fix(router): document BCK.ROUTER.0010 across every Router surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0010 landed in nvm-monorepo on 2026-08-07 (fdf057ffd, the #2191 fee accrual) and was documented nowhere: not in the skill, not on the docs site, not in any of the four IDE rule files. It is the most retry-dangerous code the Router can return, and the danger is not visible from the status alone. It is a 500 — and 0006, our only other 500, is the one entry in every table marked retryable. An agent that generalises 'retry the 5xx' lands on exactly the wrong behaviour: - no budget was reserved and no payment record was written, - but a payment credential WAS already minted (a Stripe SPT on the card rail), - so requestId cannot suppress the retry — idempotency is enforced against the payment record, and there isn't one, - and the cause is a deterministic defect in the rail's approxCents derivation, so every retry mints a fresh credential and fails identically. Added to SKILL.md, references/errors.md, products/router/guardrails.mdx and all four IDE rule files, with the 'do not generalise to retry 5xx' warning stated explicitly rather than left implicit in the retryable column. Windsurf's file stays within its 6,000-character limit (5,420). The generated development-guide/api-errors/codes.mdx is not touched here — it is synced from nvm-monorepo and PR #287 already carries 0010. Co-Authored-By: Claude Opus 5 (1M context) --- .amazonq/rules/nevermined-router.md | 2 ++ .clinerules/nevermined-router.md | 2 ++ .cursor/rules/nevermined-router.mdc | 2 ++ .windsurf/rules/nevermined-router.md | 2 ++ products/router/guardrails.mdx | 7 ++++++ skills/nevermined-router/SKILL.md | 6 ++++- skills/nevermined-router/references/errors.md | 22 +++++++++++++++++++ 7 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.amazonq/rules/nevermined-router.md b/.amazonq/rules/nevermined-router.md index 039c2619..70dfaba3 100644 --- a/.amazonq/rules/nevermined-router.md +++ b/.amazonq/rules/nevermined-router.md @@ -106,6 +106,8 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs - `BCK.ROUTER.0008` (403) — legacy API key; create a new one. - Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything else is a decision — retrying unchanged gives the same answer. +- `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and + no record was written, so `requestId` will not suppress the retry. Report it. **Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one diff --git a/.clinerules/nevermined-router.md b/.clinerules/nevermined-router.md index a1897952..962ddac5 100644 --- a/.clinerules/nevermined-router.md +++ b/.clinerules/nevermined-router.md @@ -115,6 +115,8 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs - `BCK.ROUTER.0008` (403) — legacy API key; create a new one. - Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything else is a decision — retrying unchanged gives the same answer. +- `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and + no record was written, so `requestId` will not suppress the retry. Report it. **Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one diff --git a/.cursor/rules/nevermined-router.mdc b/.cursor/rules/nevermined-router.mdc index 30b6992b..516db97d 100644 --- a/.cursor/rules/nevermined-router.mdc +++ b/.cursor/rules/nevermined-router.mdc @@ -111,6 +111,8 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs - `BCK.ROUTER.0008` (403) — legacy API key; create a new one. - Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything else is a decision — retrying unchanged gives the same answer. +- `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and + no record was written, so `requestId` will not suppress the retry. Report it. **Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one diff --git a/.windsurf/rules/nevermined-router.md b/.windsurf/rules/nevermined-router.md index 039c2619..70dfaba3 100644 --- a/.windsurf/rules/nevermined-router.md +++ b/.windsurf/rules/nevermined-router.md @@ -106,6 +106,8 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs - `BCK.ROUTER.0008` (403) — legacy API key; create a new one. - Only `BCK.ROUTER.0006` (500) and `0007` (429, too many concurrent) are **retryable**. Everything else is a decision — retrying unchanged gives the same answer. +- `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and + no record was written, so `requestId` will not suppress the retry. Report it. **Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one diff --git a/products/router/guardrails.mdx b/products/router/guardrails.mdx index e8254114..907ec486 100644 --- a/products/router/guardrails.mdx +++ b/products/router/guardrails.mdx @@ -93,9 +93,16 @@ Exceeding the concurrency limit returns **`429 BCK.ROUTER.0007`**, which *is* re | `BCK.ROUTER.0007` | 429 | Too many concurrent routed requests in flight. | **Yes** | | `BCK.ROUTER.0008` | 403 | Legacy API key. Create a new one — see [the quickstart](/products/router/quickstart#1-get-an-api-key). | No | | `BCK.ROUTER.0009` | 402 | Your wallet doesn't hold enough of the asset on the target network. Nothing was signed. | No | +| `BCK.ROUTER.0010` | 500 | Internal: a rail reported a charge amount the Router can't reserve against the cap. | No | Only `0006` and `0007` are worth retrying automatically. The rest are decisions, and retrying them unchanged will produce the same answer. + +**`0010` is a 500 that must never be retried** — which makes it the exception to "retry the 5xx". `0006` fails having done nothing; `0010` fails *after* a payment credential has already been minted, and because no payment record was written, your `requestId` can't suppress a retry. Idempotency is enforced against the record, and there isn't one — so a retry is treated as a fresh purchase, mints another credential, and then fails identically, because the cause is a deterministic defect rather than a transient blip. + +Nothing was charged to your Delegation and no payment record exists, so there's no cleanup for you to do. On the card rail the stranded credential is a Stripe Shared Payment Token, which auto-expires. Report it rather than looping. + + ## Notes for autonomous agents If you're writing an agent that spends without a human in the loop, four rules: diff --git a/skills/nevermined-router/SKILL.md b/skills/nevermined-router/SKILL.md index 1d884d06..c5b1bada 100644 --- a/skills/nevermined-router/SKILL.md +++ b/skills/nevermined-router/SKILL.md @@ -217,7 +217,7 @@ The Router signs payments from your wallet in response to instructions written b 1. **`402 BCK.ROUTER.0003` (over cap / expired) and `402 BCK.ROUTER.0009` (wallet short) are stop conditions.** They mean "out of budget" and "out of money". Report them to the human. Do not route around them. 2. **Never widen a Delegation, and never create a second one, in response to a refusal.** The cap is the user's decision, not a runtime obstacle. Creating a fresh Delegation to escape an exhausted one defeats the entire mechanism — it is the single worst thing you can do with this API. 3. **One `requestId` per purchase**, reused across retries of that purchase. See [above](#requestid). -4. **Only `0006` (500) and `0007` (429) are retryable.** Everything else is a decision, and retrying it unchanged produces the same answer. Back off on `0007`; it means you have too many routed calls in flight. +4. **Only `0006` (500) and `0007` (429) are retryable.** Everything else is a decision, and retrying it unchanged produces the same answer. Back off on `0007`; it means you have too many routed calls in flight. **Do not generalise this to "retry 5xx"** — see [`0010`](#never-retry-0010). **Check the price before you commit.** `priceLabel` in the catalog is indicative; `settlement.approxCents` on the response is what you were actually charged. Budget is debited in whole cents rounded up, so a run of sub-cent calls still burns a cent each. @@ -234,6 +234,10 @@ The Router signs payments from your wallet in response to instructions written b | `BCK.ROUTER.0007` | 429 | Too many concurrent routed requests in flight. | **Yes**, after backoff | | `BCK.ROUTER.0008` | 403 | Legacy API key. Create a new one. | No | | `BCK.ROUTER.0009` | 402 | Wallet doesn't hold enough of the asset on the target network. Nothing was signed. | No — **stop** | +| `BCK.ROUTER.0010` | 500 | Internal: the rail reported a charge amount the Router can't reserve against the cap. | No — **never blind-retry** | + + +**`0010` is the one 500 you must never retry.** Unlike `0006`, a payment credential **was already minted** before it failed — and because no payment record was written, your `requestId` will *not* suppress the retry. So a retry mints a **fresh** credential and then fails identically, because the cause is a deterministic defect in the rail's amount derivation, not a transient blip. Report it to the human. (On the card rail the stranded credential is a Stripe Shared Payment Token, which auto-expires.) Catalog errors: `BCK.CATALOG.0001` (404, no listed service with that slug — slugs are case-sensitive), `BCK.CATALOG.0002` (500, transient, retryable), `BCK.CATALOG.0003` (400, `protocol` filter must be one of `x402`, `mpp`, `rest`, `a2a`, `other`). diff --git a/skills/nevermined-router/references/errors.md b/skills/nevermined-router/references/errors.md index d1f41c9b..6514c565 100644 --- a/skills/nevermined-router/references/errors.md +++ b/skills/nevermined-router/references/errors.md @@ -20,10 +20,32 @@ obstacles is exactly the failure mode this design exists to prevent. | `BCK.ROUTER.0007` | 429 | Too many concurrent routed requests in flight | **Yes**, after backoff | | `BCK.ROUTER.0008` | 403 | Legacy API key — create a new one | No | | `BCK.ROUTER.0009` | 402 | Wallet doesn't hold enough of the asset on the target network. **Nothing was signed** | No — **stop** | +| `BCK.ROUTER.0010` | 500 | Internal: the rail reported a charge amount that isn't a non-negative integer, so the Router can't reserve anything against the cap | No — **never blind-retry** | **Only `0006` and `0007` are worth retrying automatically.** The rest are decisions; retrying them unchanged produces the same answer. +### `0010` — the 500 you must not retry + +`0006` and `0010` are both 500s and behave in opposite ways, so "retry 5xx" is the wrong reflex here. + +`0010` means a payment handler reported a settlement amount in cents that isn't a non-negative +integer, so the routing-fee arithmetic can't compute what to reserve. It deliberately fails rather +than defaulting to zero — reserving nothing would let the payment through free. + +What that leaves behind is the important part: + +- **No budget was reserved and no payment record was written.** +- **But a payment credential WAS already minted.** On the card rail that's a Stripe Shared Payment + Token, which auto-expires. +- **Therefore your `requestId` cannot protect you.** Idempotency is enforced against the payment + record, and there is no record — so a retry is treated as a brand-new purchase and mints a + **fresh** credential. +- The cause is a deterministic defect in that rail's `approxCents` derivation, not a transient + blip, so the retry fails in exactly the same way. + +Report it to the human. Do not loop. + Catalog codes: `BCK.CATALOG.0001` (404, unknown slug — case-sensitive), `BCK.CATALOG.0002` (500, transient, retryable), `BCK.CATALOG.0003` (400, bad `protocol` filter). From ff9a24f44a478735337cc9dc7d53eafa3578822d Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Tue, 11 Aug 2026 17:44:57 +0200 Subject: [PATCH 2/6] fix(router): fold the review panel's findings on the 0010 docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of https://github.com/nevermined-io/docs/pull/291 found one Medium and three Lows, all verified against nvm-monorepo origin/main before folding: - MEDIUM — 'no cleanup for you to do ... which auto-expires' understated the card rail. Verified: SPT_MAX_EXPIRY_SECONDS is 89 days (mpp-stripe.ts:39) and the expiry is min(challenge, delegation, now+89d) (sptExpirySeconds), and there is NO deactivate/revoke path anywhere under apps/api/src/router/. So the token is stranded, not merely short-lived. Now states the bound and says plainly that it cannot be cleaned up from the outside. Also added the rail split the reviewer surfaced from the source: on the crypto rails the credential never leaves the Router process on this path, so no funds can move and nothing is at risk. Only the card rail strands anything. That is the difference between 'alarming' and 'actionable'. - LOW — guardrails.mdx's Retry? cell was a bare 'No' while SKILL.md and errors.md said 'No - never blind-retry'. The public page carried the weakest signal of the three; all three now match. - LOW — 'Unlike 0006' was apples-to-oranges: 0006 is raised only by the payments summary read, never by a payment, so on the paying path 0007 is the only retryable code at all. Said outright now. - LOW — 0010 is currently unreachable: every rail feeds parseMerchantCents an already-validated numeric value, so seeing it is a Nevermined-side regression. Stated, to push readers toward 'report' over 'handle'. Co-Authored-By: Claude Opus 5 (1M context) --- products/router/guardrails.mdx | 8 +++++--- skills/nevermined-router/SKILL.md | 4 +++- skills/nevermined-router/references/errors.md | 16 ++++++++++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/products/router/guardrails.mdx b/products/router/guardrails.mdx index 907ec486..828cc526 100644 --- a/products/router/guardrails.mdx +++ b/products/router/guardrails.mdx @@ -93,14 +93,16 @@ Exceeding the concurrency limit returns **`429 BCK.ROUTER.0007`**, which *is* re | `BCK.ROUTER.0007` | 429 | Too many concurrent routed requests in flight. | **Yes** | | `BCK.ROUTER.0008` | 403 | Legacy API key. Create a new one — see [the quickstart](/products/router/quickstart#1-get-an-api-key). | No | | `BCK.ROUTER.0009` | 402 | Your wallet doesn't hold enough of the asset on the target network. Nothing was signed. | No | -| `BCK.ROUTER.0010` | 500 | Internal: a rail reported a charge amount the Router can't reserve against the cap. | No | +| `BCK.ROUTER.0010` | 500 | Internal: a rail reported a charge amount the Router can't reserve against the cap. | No — **never blind-retry** | Only `0006` and `0007` are worth retrying automatically. The rest are decisions, and retrying them unchanged will produce the same answer. -**`0010` is a 500 that must never be retried** — which makes it the exception to "retry the 5xx". `0006` fails having done nothing; `0010` fails *after* a payment credential has already been minted, and because no payment record was written, your `requestId` can't suppress a retry. Idempotency is enforced against the record, and there isn't one — so a retry is treated as a fresh purchase, mints another credential, and then fails identically, because the cause is a deterministic defect rather than a transient blip. +**`0010` is a 500 that must never be retried** — which makes it the exception to "retry the 5xx". In fact `0006`, the one retryable 500, is raised only by the payments *summary* read and never by a payment, so on the paying path **`0007` is the only code worth retrying at all**. -Nothing was charged to your Delegation and no payment record exists, so there's no cleanup for you to do. On the card rail the stranded credential is a Stripe Shared Payment Token, which auto-expires. Report it rather than looping. +`0010` fails *after* a payment credential has already been minted, and because no payment record was written, your `requestId` can't suppress a retry. Idempotency is enforced against the record, and there isn't one — so a retry is treated as a fresh purchase, mints another credential, and then fails identically, because the cause is a deterministic defect rather than a transient blip. + +Nothing was charged to your Delegation and no payment record exists. On the card rail the minted credential is a Stripe Shared Payment Token which is left stranded: there is no revoke path, so it stands until it expires, and that expiry is `min(the merchant challenge's expiry, your Delegation's expiry, 89 days)` — not necessarily soon. Nothing you can do about it from the outside, which is precisely why this should be reported rather than looped on. ## Notes for autonomous agents diff --git a/skills/nevermined-router/SKILL.md b/skills/nevermined-router/SKILL.md index c5b1bada..4e496c9a 100644 --- a/skills/nevermined-router/SKILL.md +++ b/skills/nevermined-router/SKILL.md @@ -237,7 +237,9 @@ The Router signs payments from your wallet in response to instructions written b | `BCK.ROUTER.0010` | 500 | Internal: the rail reported a charge amount the Router can't reserve against the cap. | No — **never blind-retry** | -**`0010` is the one 500 you must never retry.** Unlike `0006`, a payment credential **was already minted** before it failed — and because no payment record was written, your `requestId` will *not* suppress the retry. So a retry mints a **fresh** credential and then fails identically, because the cause is a deterministic defect in the rail's amount derivation, not a transient blip. Report it to the human. (On the card rail the stranded credential is a Stripe Shared Payment Token, which auto-expires.) +**`0010` is the one 500 you must never retry.** A payment credential **was already minted** before it failed — and because no payment record was written, your `requestId` will *not* suppress the retry. So a retry mints a **fresh** credential and then fails identically, because the cause is a deterministic defect in the rail's amount derivation, not a transient blip. Report it to the human. + +Note `0006`, the retryable 500, is only ever raised by the payments *summary* read — never by a payment. **On the paying path `0007` is the only code worth retrying at all.** And seeing `0010` at all means a Nevermined-side regression: no rail emits a non-numeric amount today, so it is a bug report, not a condition to handle. (On the card rail the minted credential is a Stripe Shared Payment Token, left stranded with no revoke path until `min(challenge expiry, Delegation expiry, 89 days)`.) Catalog errors: `BCK.CATALOG.0001` (404, no listed service with that slug — slugs are case-sensitive), `BCK.CATALOG.0002` (500, transient, retryable), `BCK.CATALOG.0003` (400, `protocol` filter must be one of `x402`, `mpp`, `rest`, `a2a`, `other`). diff --git a/skills/nevermined-router/references/errors.md b/skills/nevermined-router/references/errors.md index 6514c565..137e03d8 100644 --- a/skills/nevermined-router/references/errors.md +++ b/skills/nevermined-router/references/errors.md @@ -27,7 +27,9 @@ unchanged produces the same answer. ### `0010` — the 500 you must not retry -`0006` and `0010` are both 500s and behave in opposite ways, so "retry 5xx" is the wrong reflex here. +`0006` and `0010` are both 500s and behave in opposite ways, so "retry 5xx" is the wrong reflex +here. Note also that `0006` is raised **only by the payments summary read**, never by a payment — +so on the paying path, `0007` is the only code worth retrying at all. `0010` means a payment handler reported a settlement amount in cents that isn't a non-negative integer, so the routing-fee arithmetic can't compute what to reserve. It deliberately fails rather @@ -36,15 +38,21 @@ than defaulting to zero — reserving nothing would let the payment through free What that leaves behind is the important part: - **No budget was reserved and no payment record was written.** -- **But a payment credential WAS already minted.** On the card rail that's a Stripe Shared Payment - Token, which auto-expires. +- **But a payment credential WAS already minted**, because the fee is quoted after the signing step. - **Therefore your `requestId` cannot protect you.** Idempotency is enforced against the payment record, and there is no record — so a retry is treated as a brand-new purchase and mints a **fresh** credential. - The cause is a deterministic defect in that rail's `approxCents` derivation, not a transient blip, so the retry fails in exactly the same way. -Report it to the human. Do not loop. +**How much that actually costs you depends on the rail.** On the crypto rails the credential never +leaves the Router process on this path, so no funds can move and nothing is at risk. On the card +rail it is a Stripe Shared Payment Token that is left **stranded**: there is no revoke path, so it +stands until `min(the merchant challenge's expiry, your Delegation's expiry, 89 days)`. You cannot +clean it up from the outside. + +**Seeing `0010` at all is a Nevermined-side regression.** No rail emits a non-numeric amount today, +so this is a bug to report, not a condition to handle. Report it to the human. Do not loop. Catalog codes: `BCK.CATALOG.0001` (404, unknown slug — case-sensitive), `BCK.CATALOG.0002` (500, transient, retryable), `BCK.CATALOG.0003` (400, bad `protocol` filter). From 97457ceba374be7bbd1373212b1a8393e529b118 Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Tue, 11 Aug 2026 17:45:19 +0200 Subject: [PATCH 3/6] docs(router): reconcile the two adjacent retry statements in guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nit: line 98 said 'only 0006 and 0007 are worth retrying' while the warning immediately below said 0006 is never raised by a payment. Neither was wrong — one is about the whole API surface, the other about the paying path — but they sat adjacent and the reader had to reconcile them. Scoped explicitly and forward-referenced instead. Co-Authored-By: Claude Opus 5 (1M context) --- products/router/guardrails.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/products/router/guardrails.mdx b/products/router/guardrails.mdx index 828cc526..b39b34d6 100644 --- a/products/router/guardrails.mdx +++ b/products/router/guardrails.mdx @@ -95,7 +95,7 @@ Exceeding the concurrency limit returns **`429 BCK.ROUTER.0007`**, which *is* re | `BCK.ROUTER.0009` | 402 | Your wallet doesn't hold enough of the asset on the target network. Nothing was signed. | No | | `BCK.ROUTER.0010` | 500 | Internal: a rail reported a charge amount the Router can't reserve against the cap. | No — **never blind-retry** | -Only `0006` and `0007` are worth retrying automatically. The rest are decisions, and retrying them unchanged will produce the same answer. +Across the whole Router surface, only `0006` and `0007` are worth retrying automatically. The rest are decisions, and retrying them unchanged will produce the same answer. On the *paying* path the set is narrower still — see below. **`0010` is a 500 that must never be retried** — which makes it the exception to "retry the 5xx". In fact `0006`, the one retryable 500, is raised only by the payments *summary* read and never by a payment, so on the paying path **`0007` is the only code worth retrying at all**. From 867a86307e025f56183a5ffa54f58825ebc9c008 Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Tue, 11 Aug 2026 17:51:11 +0200 Subject: [PATCH 4/6] fix(router): a same-requestId retry returns 409, not the resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carried back from #289's review, where the condensed copies inherited this wording from SKILL.md:195. 'Retrying with the same id returns the original payment' reads as the retry SUCCEEDING. It does not: router.service.ts:156-166 looks up (userId, requestId) with no status filter and throws 409 BCK.ROUTER.0002 carrying the original paymentId. The merchant's response is never re-delivered, and when a mode-B paid hop fails the record is marked Failed while the id stays consumed — so that purchase can never complete under that id. The hazard is the recovery path, not the sentence. An agent told the retry returns its payment retries, gets an unexpected 409, and the obvious escape from a 409 is a fresh id — which is precisely the double-spend the idempotency key exists to prevent. The 0002 row says 'No' to retrying but never said 'and don't mint a new id either'. Fixed at the source (SKILL.md) and in the two other surfaces that carried the same summary (references/paying.md, products/router/guardrails.mdx); the two condensed files are fixed in #289. Co-Authored-By: Claude Opus 5 (1M context) --- products/router/guardrails.mdx | 2 +- skills/nevermined-router/SKILL.md | 2 +- skills/nevermined-router/references/paying.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/products/router/guardrails.mdx b/products/router/guardrails.mdx index b39b34d6..f28bb634 100644 --- a/products/router/guardrails.mdx +++ b/products/router/guardrails.mdx @@ -54,7 +54,7 @@ At most one payment is minted per `(caller, requestId)`. A duplicate returns **` Use **one stable id per logical purchase**, not per HTTP attempt: -- Retrying a timed-out call with the **same** id → returns the original payment. Safe. +- Retrying a timed-out call with the **same** id → `409 BCK.ROUTER.0002` with the original `paymentId`, **not the resource**. Safe — but don't escape that 409 by minting a fresh id. - Retrying with a **fresh** id → buys again. Also safe, but only if that's what you meant. ## What the Router won't fetch diff --git a/skills/nevermined-router/SKILL.md b/skills/nevermined-router/SKILL.md index 4e496c9a..e98a50b9 100644 --- a/skills/nevermined-router/SKILL.md +++ b/skills/nevermined-router/SKILL.md @@ -192,7 +192,7 @@ curl -sX POST "$NVM_API_URL/api/v1/router/route" \ `status` and `body` are the merchant's own, unchanged. `paid: false` with no `payment` block means the resource was free — the Router relayed it and charged nothing. -**`requestId` is required, and it is an idempotency key — not a request counter.** Use **one stable id per logical purchase** and reuse it across retries of that purchase. Retrying a dropped call with the same id returns the original payment instead of buying twice; a fresh id buys twice, on purpose. Derive it from the work you are doing (`"search-nevermined-router-v1"`), not from `uuid4()` per HTTP attempt — a fresh UUID on every retry is how an agent double-spends. +**`requestId` is required, and it is an idempotency key — not a request counter.** Use **one stable id per logical purchase** and reuse it across retries of that purchase. Retrying a dropped call with the same id returns `409 BCK.ROUTER.0002` carrying the original `paymentId` — **not the resource** — instead of buying twice; a fresh id buys twice, on purpose. **Never answer that 409 by minting a fresh id**: that is the double-spend the key just prevented. If the purchase genuinely failed, report it. Derive it from the work you are doing (`"search-nevermined-router-v1"`), not from `uuid4()` per HTTP attempt — a fresh UUID on every retry is how an agent double-spends. Mode A (you call the merchant yourself), the streaming `/proxy` variant, and passing the merchant's own auth: `references/paying.md`. diff --git a/skills/nevermined-router/references/paying.md b/skills/nevermined-router/references/paying.md index 4dceffb4..552bf830 100644 --- a/skills/nevermined-router/references/paying.md +++ b/skills/nevermined-router/references/paying.md @@ -82,7 +82,7 @@ you actually wanted. **Use one stable id per logical purchase, reused across retries of that purchase.** -- Same id on retry → returns the original payment. Safe. +- Same id on retry → `409 BCK.ROUTER.0002` with the original `paymentId`, **not the resource**. Safe — and never escape that 409 with a fresh id. - Fresh id on retry → buys again. Also safe, *if that is what you meant*. Derive it from the work (`"search-nevermined-router-v1"`, a hash of the query, a task id). **A fresh From 234051ad00ee18f0810873aa391ecd96d13363b2 Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Wed, 12 Aug 2026 10:57:22 +0200 Subject: [PATCH 5/6] fix(router): document BCK.ROUTER.0011 (3-D Secure) alongside 0010 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aaitor's review caught this and he is right: 0011 landed on nvm-monorepo origin/main on 2026-08-11 (908bae1de), the same day this PR was authored, so my enumeration of 'exactly 0001-0010' was correct when I ran it and stale by the time it was published. Verified against origin/main before folding: 402, category business, retryable false. It is the MORE dangerous of the pair, which is why it is worth holding the PR for. 0010 needs a Nevermined-side regression to fire at all — this PR says so. 0011 fires on normal operation of any 3-D-Secure card, and carries the same mint-and-abandon trap: per its hint, 3DS is often mandated per charge, so every retry re-demands it and 'each attempt creates a fresh single-use card credential that is then abandoned', each expiring at min(challenge, delegation, 89 days). Taking his second point too, which is the better framing: the real lesson is not '0010 is the exception to retry the 5xx' but that THE HTTP STATUS DOES NOT TELL YOU WHETHER TO RETRY. 0010 is a 500 you must not retry; 0011 is a 402 that reads like a routine payment error and equally must not be. A reader who internalised 'the one status trap is the 500' walks straight into the 402 one. Stated in SKILL.md rule 4 and in the guardrails Warning. Added to all seven surfaces. errors.md gets a full subsection mirroring 0010's, including that 0011 is distinct from BOTH other 402s (0003 is the cap, 0009 is a card refused for lack of funds; here the card is fine, just unauthenticated). Windsurf stays under its limit at 5,666 of 6,000. Co-Authored-By: Claude Opus 5 (1M context) --- .amazonq/rules/nevermined-router.md | 3 +++ .clinerules/nevermined-router.md | 3 +++ .cursor/rules/nevermined-router.mdc | 3 +++ .windsurf/rules/nevermined-router.md | 3 +++ products/router/guardrails.mdx | 3 +++ skills/nevermined-router/SKILL.md | 5 ++++- skills/nevermined-router/references/errors.md | 16 ++++++++++++++++ 7 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.amazonq/rules/nevermined-router.md b/.amazonq/rules/nevermined-router.md index 70dfaba3..43478e67 100644 --- a/.amazonq/rules/nevermined-router.md +++ b/.amazonq/rules/nevermined-router.md @@ -108,6 +108,9 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs else is a decision — retrying unchanged gives the same answer. - `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and no record was written, so `requestId` will not suppress the retry. Report it. +- `BCK.ROUTER.0011` (402) — card rail: needs cardholder 3-D Secure, which an agent can't complete. + Nothing was charged. **Don't auto-retry** — each attempt re-demands 3DS and strands a fresh + single-use card credential. Surface it to a human. **Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one diff --git a/.clinerules/nevermined-router.md b/.clinerules/nevermined-router.md index 962ddac5..48a084c3 100644 --- a/.clinerules/nevermined-router.md +++ b/.clinerules/nevermined-router.md @@ -117,6 +117,9 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs else is a decision — retrying unchanged gives the same answer. - `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and no record was written, so `requestId` will not suppress the retry. Report it. +- `BCK.ROUTER.0011` (402) — card rail: needs cardholder 3-D Secure, which an agent can't complete. + Nothing was charged. **Don't auto-retry** — each attempt re-demands 3DS and strands a fresh + single-use card credential. Surface it to a human. **Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one diff --git a/.cursor/rules/nevermined-router.mdc b/.cursor/rules/nevermined-router.mdc index 516db97d..806d4182 100644 --- a/.cursor/rules/nevermined-router.mdc +++ b/.cursor/rules/nevermined-router.mdc @@ -113,6 +113,9 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs else is a decision — retrying unchanged gives the same answer. - `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and no record was written, so `requestId` will not suppress the retry. Report it. +- `BCK.ROUTER.0011` (402) — card rail: needs cardholder 3-D Secure, which an agent can't complete. + Nothing was charged. **Don't auto-retry** — each attempt re-demands 3DS and strands a fresh + single-use card credential. Surface it to a human. **Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one diff --git a/.windsurf/rules/nevermined-router.md b/.windsurf/rules/nevermined-router.md index 70dfaba3..43478e67 100644 --- a/.windsurf/rules/nevermined-router.md +++ b/.windsurf/rules/nevermined-router.md @@ -108,6 +108,9 @@ Budget is debited in **whole cents, rounded up** — 1000 calls at $0.001 costs else is a decision — retrying unchanged gives the same answer. - `BCK.ROUTER.0010` (500) — internal. **Never blind-retry it:** a credential was already minted and no record was written, so `requestId` will not suppress the retry. Report it. +- `BCK.ROUTER.0011` (402) — card rail: needs cardholder 3-D Secure, which an agent can't complete. + Nothing was charged. **Don't auto-retry** — each attempt re-demands 3DS and strands a fresh + single-use card credential. Surface it to a human. **Never widen a Delegation, and never create a second one, to get past a refusal.** The cap is the user's decision, not a runtime obstacle; minting a fresh Delegation to escape an exhausted one diff --git a/products/router/guardrails.mdx b/products/router/guardrails.mdx index fee66089..bc01c53d 100644 --- a/products/router/guardrails.mdx +++ b/products/router/guardrails.mdx @@ -98,6 +98,7 @@ Exceeding the concurrency limit returns **`429 BCK.ROUTER.0007`**, which *is* re | `BCK.ROUTER.0008` | 403 | Legacy API key. Create a new one — see [the quickstart](/products/router/quickstart#1-get-an-api-key). | No | | `BCK.ROUTER.0009` | 402 | You couldn't fund the payment. MPP only, and it covers two cases: MPP-tempo read your balance and found it short, or MPP-stripe attempted your card and it declined. Either way nothing was signed and no budget was reserved. x402 never raises this — it has no balance check, so a short wallet fails on-chain instead. | No | | `BCK.ROUTER.0010` | 500 | Internal: a rail reported a charge amount the Router can't reserve against the cap. | No — **never blind-retry** | +| `BCK.ROUTER.0011` | 402 | Card rail: the charge needs cardholder 3-D Secure, and an agent has no browser to complete it. Nothing was charged and the seller got no usable credential. | No — **needs a human** | Across the whole Router surface, only `0006` and `0007` are worth retrying automatically. The rest are decisions, and retrying them unchanged will produce the same answer. On the *paying* path the set is narrower still — see below. @@ -106,6 +107,8 @@ Across the whole Router surface, only `0006` and `0007` are worth retrying autom `0010` fails *after* a payment credential has already been minted, and because no payment record was written, your `requestId` can't suppress a retry. Idempotency is enforced against the record, and there isn't one — so a retry is treated as a fresh purchase, mints another credential, and then fails identically, because the cause is a deterministic defect rather than a transient blip. +**And `0011` is its mirror image on the other side of the status code.** A card needing 3-D Secure returns a `402` that reads like a routine payment error, and is equally un-retryable: the Router can't complete the challenge, so every retry re-demands it and strands another single-use card credential. Route it to a human. The general lesson is that **the HTTP status does not tell you whether to retry** — read the code. + Nothing was charged to your Delegation and no payment record exists. On the card rail the minted credential is a Stripe Shared Payment Token which is left stranded: there is no revoke path, so it stands until it expires, and that expiry is `min(the merchant challenge's expiry, your Delegation's expiry, 89 days)` — not necessarily soon. Nothing you can do about it from the outside, which is precisely why this should be reported rather than looped on. diff --git a/skills/nevermined-router/SKILL.md b/skills/nevermined-router/SKILL.md index e98a50b9..e8ff6fd5 100644 --- a/skills/nevermined-router/SKILL.md +++ b/skills/nevermined-router/SKILL.md @@ -217,7 +217,7 @@ The Router signs payments from your wallet in response to instructions written b 1. **`402 BCK.ROUTER.0003` (over cap / expired) and `402 BCK.ROUTER.0009` (wallet short) are stop conditions.** They mean "out of budget" and "out of money". Report them to the human. Do not route around them. 2. **Never widen a Delegation, and never create a second one, in response to a refusal.** The cap is the user's decision, not a runtime obstacle. Creating a fresh Delegation to escape an exhausted one defeats the entire mechanism — it is the single worst thing you can do with this API. 3. **One `requestId` per purchase**, reused across retries of that purchase. See [above](#requestid). -4. **Only `0006` (500) and `0007` (429) are retryable.** Everything else is a decision, and retrying it unchanged produces the same answer. Back off on `0007`; it means you have too many routed calls in flight. **Do not generalise this to "retry 5xx"** — see [`0010`](#never-retry-0010). +4. **Only `0006` (500) and `0007` (429) are retryable.** Everything else is a decision, and retrying it unchanged produces the same answer. Back off on `0007`; it means you have too many routed calls in flight. **The HTTP status does not tell you whether to retry** — `0010` is a 500 you must not retry and `0011` is a 402 you must not retry. Read the code, not the status. **Check the price before you commit.** `priceLabel` in the catalog is indicative; `settlement.approxCents` on the response is what you were actually charged. Budget is debited in whole cents rounded up, so a run of sub-cent calls still burns a cent each. @@ -235,8 +235,11 @@ The Router signs payments from your wallet in response to instructions written b | `BCK.ROUTER.0008` | 403 | Legacy API key. Create a new one. | No | | `BCK.ROUTER.0009` | 402 | Wallet doesn't hold enough of the asset on the target network. Nothing was signed. | No — **stop** | | `BCK.ROUTER.0010` | 500 | Internal: the rail reported a charge amount the Router can't reserve against the cap. | No — **never blind-retry** | +| `BCK.ROUTER.0011` | 402 | Card rail: the charge needs cardholder 3-D Secure, and an agent has no browser to complete it. Nothing was charged and the seller got no usable credential. | No — **needs a human** | +**`0011` needs a human, not a retry.** The card issuer is demanding 3-D Secure and the Router has no browser to answer it. Nothing was charged. Do **not** loop: 3DS is often mandated per charge, so every attempt re-demands it and mints a fresh single-use card credential that is then abandoned. A later *human-driven* attempt may succeed — that is a decision, not a retry. + **`0010` is the one 500 you must never retry.** A payment credential **was already minted** before it failed — and because no payment record was written, your `requestId` will *not* suppress the retry. So a retry mints a **fresh** credential and then fails identically, because the cause is a deterministic defect in the rail's amount derivation, not a transient blip. Report it to the human. Note `0006`, the retryable 500, is only ever raised by the payments *summary* read — never by a payment. **On the paying path `0007` is the only code worth retrying at all.** And seeing `0010` at all means a Nevermined-side regression: no rail emits a non-numeric amount today, so it is a bug report, not a condition to handle. (On the card rail the minted credential is a Stripe Shared Payment Token, left stranded with no revoke path until `min(challenge expiry, Delegation expiry, 89 days)`.) diff --git a/skills/nevermined-router/references/errors.md b/skills/nevermined-router/references/errors.md index 137e03d8..4cfa1d01 100644 --- a/skills/nevermined-router/references/errors.md +++ b/skills/nevermined-router/references/errors.md @@ -21,10 +21,26 @@ obstacles is exactly the failure mode this design exists to prevent. | `BCK.ROUTER.0008` | 403 | Legacy API key — create a new one | No | | `BCK.ROUTER.0009` | 402 | Wallet doesn't hold enough of the asset on the target network. **Nothing was signed** | No — **stop** | | `BCK.ROUTER.0010` | 500 | Internal: the rail reported a charge amount that isn't a non-negative integer, so the Router can't reserve anything against the cap | No — **never blind-retry** | +| `BCK.ROUTER.0011` | 402 | Card rail: the charge needs cardholder 3-D Secure, and an agent has no browser to complete it. Nothing was charged and the seller got no usable credential. | No — **needs a human** | **Only `0006` and `0007` are worth retrying automatically.** The rest are decisions; retrying them unchanged produces the same answer. +### `0011` — the 402 that needs a human, not a retry + +Card rail only. The issuer demands **3-D Secure / SCA** before the charge can be used, and the Router +has no human at a browser to complete it. **Nothing was charged, and the seller never received a +usable credential** — the one Stripe created cannot be charged while it awaits authentication. + +Do **not** auto-retry. 3DS is often mandated per charge by industry rules, so every attempt +re-demands it and mints another single-use card credential that is then abandoned — each expiring on +its own at `min(the merchant's quoted expiry, your Delegation's expiry, 89 days)`. A later attempt +*may* succeed, since whether authentication is demanded is decided per charge by the issuer, the card +networks and Stripe's risk checks — but treat that as a human decision, not a loop. + +It is distinct from both other 402s: `0003` is your cap, `0009` is a card refused for lack of funds. +Here the card is fine; it simply has not been authenticated for this charge. + ### `0010` — the 500 you must not retry `0006` and `0010` are both 500s and behave in opposite ways, so "retry 5xx" is the wrong reflex From 6df4a293b3ffdcd1c4dc6a0f089c23d93bc48d0b Mon Sep 17 00:00:00 2001 From: Rodolphe Marques Date: Wed, 12 Aug 2026 11:19:57 +0200 Subject: [PATCH 6/6] chore(router): drop the orphaned #never-retry-0010 anchor aaitor's LOW, and correct. Rewording rule 4 to the broader 'the HTTP status does not tell you whether to retry' dropped its [0010](#never-retry-0010) link, leaving the anchor with no inbound reference anywhere in the docs. Inserting the 0011 paragraph then pushed it above the WRONG hazard, so any lingering external deep-link to #never-retry-0010 would have landed on 0011. Took the leanest of his two options: nothing links to it, so it is dead markup. Restoring the link would have re-narrowed rule 4 to 0010 alone, which is the framing the re-review asked me to drop. Noted while auditing the rest: #never-widen is also orphaned, but it is pre-existing on origin/main and out of scope here. Co-Authored-By: Claude Opus 5 (1M context) --- skills/nevermined-router/SKILL.md | 1 - 1 file changed, 1 deletion(-) diff --git a/skills/nevermined-router/SKILL.md b/skills/nevermined-router/SKILL.md index e8ff6fd5..e3a91d2a 100644 --- a/skills/nevermined-router/SKILL.md +++ b/skills/nevermined-router/SKILL.md @@ -237,7 +237,6 @@ The Router signs payments from your wallet in response to instructions written b | `BCK.ROUTER.0010` | 500 | Internal: the rail reported a charge amount the Router can't reserve against the cap. | No — **never blind-retry** | | `BCK.ROUTER.0011` | 402 | Card rail: the charge needs cardholder 3-D Secure, and an agent has no browser to complete it. Nothing was charged and the seller got no usable credential. | No — **needs a human** | - **`0011` needs a human, not a retry.** The card issuer is demanding 3-D Secure and the Router has no browser to answer it. Nothing was charged. Do **not** loop: 3DS is often mandated per charge, so every attempt re-demands it and mints a fresh single-use card credential that is then abandoned. A later *human-driven* attempt may succeed — that is a decision, not a retry. **`0010` is the one 500 you must never retry.** A payment credential **was already minted** before it failed — and because no payment record was written, your `requestId` will *not* suppress the retry. So a retry mints a **fresh** credential and then fails identically, because the cause is a deterministic defect in the rail's amount derivation, not a transient blip. Report it to the human.