fix(router): document BCK.ROUTER.0010 and 0011 — two codes an agent must never auto-retry - #291
Conversation
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) <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Review of #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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
r-marques
left a comment
There was a problem hiding this comment.
⚠️ The PR head moved during this review (9d90d1a410d4→97457ceba374). Findings were produced against9d90d1a410d4; inline anchors were re-validated against97457ceba374, so double-check the placement of any comment that looks off.
🤖 Automated PR review — ✅ Ready to merge
Panel review of the BCK.ROUTER.0010 documentation sweep. The four load-bearing claims were verified against nvm-monorepo origin/main at the call-path level, not from the error-registry hint text — which matters, because the PR's central warning depends on an ordering the hint asserts but does not prove.
The trace, in apps/api/src/router/router.service.ts inside createPayment: the idempotency pre-check (findOne({ userId, requestId }), backed by a DB partial-unique index — both keyed on the payment record) → handler.prepare(...) → recipient scope → await prepared.sign(), which mints the credential → quoteRouterFee(...) at line 291, which is where parseMerchantCents throws 0010 → only then RouterPaymentEntity.create and the transaction doing atomicCapDecrement + save. So at the throw: credential exists, cap untouched, row never written — and a retry with the same requestId sails past step 1 and mints again. The warning is right, and right for the reason it states.
What I reviewed
- Diff range
origin/main...HEAD— 7 files, +42/−1 at review time (9d90d1a). - Panel:
pr-review-toolkit:code-reviewer, plus independent verification against nvm-monorepoorigin/main. - Enumerated every
BCK.ROUTER.*andBCK.CATALOG.*inlibs/helpers/src/lib/output/api-errors.ts: exactlyROUTER.0001–0010andCATALOG.0001–0003. No other undocumented code, and all ten existing rows match the source on status and retry flag, in both changed tables. - Sweep completeness checked on the stricter criterion — not "which files have a table" but "which files state the retry policy", since that is where an agent forms the wrong reflex. Both criteria give the same answer here.
quickstart.mdxwas correctly left alone: it is a real table, but an explicitly curated subset ("the three you're most likely to meet") that links here for every code. development-guide/api-errors/codes.mdxcorrectly untouched — generated bysync-errors-docs.yml..windsurf/rules/nevermined-router.mdat 5,420 characters, under its 6,000 limit.
Findings — 1 Medium, 4 Low, all folded in before merge (ff9a24f, 97457ce):
- Medium — the card-rail reassurance was understated. The text said the stranded credential "auto-expires" and that "there's no cleanup for you to do". Verified:
SPT_MAX_EXPIRY_SECONDSis 89 days, the expiry ismin(challenge, delegation, now + 89d), and there is no deactivate or revoke path anywhere underapps/api/src/router/— so the token is stranded, not merely short-lived. Now states the bound and says plainly it can't be cleaned up from outside. - Low → improvement. Added the rail split the review surfaced from 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, and it is the strongest true statement available here.
- Low —
guardrails.mdxcarried the weakest signal of the three surfaces: a bareNoin the Retry? column whileSKILL.mdanderrors.mdsaidNo — never blind-retry. All three now match. - Low — "Unlike
0006" was apples-to-oranges.0006has exactly one throw site (router.service.ts:928, a read-only summary method), so it is never raised by a payment — meaning on the paying path0007is the only retryable code at all. Now said outright. - Low —
0010is currently unreachable. All three rails feedparseMerchantCentsa pre-validated numeric value, so seeing it is a Nevermined-side regression. Stated, to push readers toward "report" rather than "handle".
An open question about whether Stripe's shared_payment.issued_token.* webhooks reach the seller became moot: the revised wording rests on "there is no revoke path, so it stands until it expires", which is true either way.
Note on head drift: the PR head has moved since the diff above — deliberately. The review found the issues, and the two follow-up commits are the fixes. Re-verified against the new text.
Known follow-up, not a blocker: codes.mdx still says "BCK.ROUTER — 9 codes" until the sync-errors-docs.yml PR lands from nvm-monorepo (#287 carries 0010).
Verdict: ✅ Ready to merge — 0 blockers, 0 outstanding should-fix, 0 outstanding nits.
… sections Carrying across a finding from the sibling PR #291's review: 'Unlike 0006' was an apples-to-oranges comparison. BCK.ROUTER.0006 has exactly one throw site (router.service.ts:928, a read-only summary method), so it is never raised by a payment — meaning on the paying path 0007 is the only retryable code at all. Fixed in #291's three surfaces; these two carried the same wording and would otherwise have disagreed with them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Picks up #288 (router fee disclosure), which rewrote the BCK.ROUTER.0009 row in guardrails.mdx while this branch was appending 0010 after it. Conflict resolved in favour of #288's wording for 0009 — it is strictly more accurate (MPP-only, covering both the tempo balance read and the stripe card decline, and noting x402 never raises it because it has no balance check) — with this branch's 0010 row appended after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # products/router/guardrails.mdx
aaitor
left a comment
There was a problem hiding this comment.
Strong, well-researched PR — I verified all seven load-bearing 0010 claims against nvm-monorepo origin/main at the call-path level (not the hint text), and every one holds. The 0010 documentation itself is accurate and I'd merge it as written.
One blocker, and it's cheap. A same-class Router hazard, BCK.ROUTER.0011 (402, 3-D Secure), landed on origin/main on 2026-08-11 (7096629f9, #2765) — the same day this PR was authored, which is why the sweep missed it. It's undocumented in every surface this PR touches, it is more reachable than 0010 (it fires on any normal 3DS card, whereas 0010 needs a Nevermined-side regression by this PR's own admission), and it carries the identical mint-and-abandon card-credential trap. That also makes the PR body's "0010 was the only missing one" inaccurate — on origin/main there are now two. Fold it in (recommended) or defer with a tracking issue and soften the claim; either clears the gate. Full fix text is inline on references/errors.md.
Verification I ran
- Read all 8 changed files in post-PR state (
docs/document-router-0010, HEAD1dc03d8). CI green (2/2). - Call-path trace on nvm-monorepo
origin/main: idempotency pre-checkfindOne({userId, requestId})→0002409 carrying the originalpaymentId(router.service.ts:213-220;0002is httpStatus 409 inapi-errors.ts);prepared.sign()mints at:327;quoteRouterFeeat:358→parseMerchantCentsthrows0010(router-fee.ts:141) before the record write (:380+) andatomicCapDecrement(:408). So at the throw: credential exists, cap untouched, no record → a same-requestIdretry sails past the pre-check and re-mints. Matches the diff exactly, including the867a863"409, not the resource" correction. 0006has a single throw site — the payments-summary read (:1615), never the paying path → "on the paying path0007is the only retryable code" is exact.- Card-rail facts corroborated by the
router-fee.ts:188"durable Stripe SPT / burnt custodial signature" comment and, independently, by the0011hint (same SPT, samemin(challenge, delegation, 89 days), no revoke path). - Enumerated every
BCK.ROUTER.*/BCK.CATALOG.*onorigin/main:ROUTER.0001–0011+CATALOG.0001–0003.0001–0009match the docs exactly;0010is added here;0011is missing everywhere (hand-authored and generatedcodes.mdx), and #2854's five gaps do not include it. .windsurffile 5,420 chars / 5,474 bytes — under the 6,000-char limit.guardrails.mdxJSX balanced (2/2<Warning>, 2/2<Note>). All four IDE-rule bullets identical.- Over-engineering / leanness pass (
/ponytail-review): lean already, ship — net 0 removable lines. The three-tier depth (SKILL quick / references deep / guardrails site) matches the skill's existing structure; the four IDE bullets are distinct deliverables, not duplication. AGENTS.md/.github/copilot-instructions.mdcarry no Router error surface on this branch (they arrive via #289) → correctly untouched.
For the next PR in the sweep: #2854's process note is right — a CI check diffing api-errors.ts's BCK.ROUTER.* codes against the skill's table would have caught 0010 and 0011 the day each landed. 0011 is the second miss in a week from the same manual-snapshot gap; worth prioritising that guard.
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) <noreply@anthropic.com>
Keeps these in step with #291, which documents 0011 across the canonical skill, guardrails.mdx and the four IDE rule files after aaitor caught it missing. Also carries across the reframing his review prompted: the point is not that 0010 is 'the exception to retry the 5xx' but that the HTTP status does not tell you whether to retry at all — 0010 is a 500 you must not retry, 0011 a 402 that reads like an ordinary payment error and equally must not be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@aaitor — you're right, and it was worth blocking on. Folded in rather than deferred ( I verified Your point that And your second point was the better framing, so I took it over my own. I had written the lesson as " What landed
On the tracking guard — agreed, and Ready for another look. |
aaitor
left a comment
There was a problem hiding this comment.
✅ Approving — the 0011 gap is fully closed. The prior HIGH is resolved across every Router surface, re-verified against nvm-monorepo origin/main, and the one hard constraint (Windsurf's char limit) still holds. One LOW left inline — an orphaned anchor the rule-4 reframe left behind — which does not gate.
Re-review of 234051ad0 (prior review was 1dc03d8)
Resolved — my prior HIGH (BCK.ROUTER.0011 undocumented across all surfaces):
- Added to all seven surfaces: a table row + a full mirror subsection in
references/errors.md,SKILL.mdandguardrails.mdx, and a 3-line bullet in each of the four IDE rule files — consistent wording throughout. - Every
0011claim matches theapi-errors.ts0011hint onorigin/main: card-rail 3-D Secure, no money moved, seller got no usable credential, per-charge 3DS re-demand mints-and-abandons a fresh single-use SPT,min(challenge, Delegation, 89 days)expiry, may succeed on a later human-driven attempt. - You improved on my suggested text by stating
0011is distinct from both other 402s (0003= cap,0009= funds) — I checked that against the hint; it's accurate. - The
guardrails.mdxWarning now leads with0010(the 500 case), adds0011as its 402 mirror, and closes on the right general rule — HTTP status doesn't tell you whether to retry; read the code — echoed inSKILL.mdrule 4. Exactly the reframe my second comment asked for.
Verification I ran
- Diffed
234051ad0against my prior findings; read every changed file in post-commit state. CI green (Mintlify deploy + link-rot pass; vale skipped). - Re-verified
0011againstapi-errors.ts(BCK.ROUTER.0011, httpStatus 402) onorigin/main. Full Router set is now documented end-to-end:0001–0011. - Windsurf budget re-checked: 5,666 chars / 5,724 bytes — still under the 6,000-char limit (334 headroom). The new bullet fit.
- JSX unchanged — the
0011paragraph is plain text inside the existing<Warning>, so balance stays 2/2. - Over-engineering / leanness pass: the only thing to trim is the now-dead
#never-retry-0010anchor (LOW, inline). Otherwise net 0 removable.
Cosmetic, non-blocking: the PR body's "0010 was the only missing one" line is now doubly stale — worth a one-line tweak when you merge, but it isn't shipped content.
Nice work — this is a genuinely complete Router error-doc sweep now.
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) <noreply@anthropic.com>
Picks up #291 (BCK.ROUTER.0010 + 0011 across the canonical skill, guardrails and the four IDE rule files). The two PRs share zero files, so this is a clean sync — taken so the Mintlify preview reflects the reconciled 0010/0011 state before merge, as the review asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Independent of #289 and #290 — branches off
main, touches different files, merge in any order.The gap
BCK.ROUTER.0010landed in nvm-monorepo on 2026-08-07 (fdf057ffd, the #2191 fee accrual) and is documented nowhere — not inSKILL.md, not inreferences/errors.md(whose table is headed "Every Router code" and stops at 0009), not on the docs site, not in any of the four IDE rule files.Found while shipping #289, by re-verifying the skill's facts against the current API rather than the state they were authored against.
Why this one matters more than a missing table row
It's the most retry-dangerous code the Router can return, and the danger isn't visible from the status alone. It's a
500— and0006, our only other 500, is the single entry in every table marked retryable. An agent that generalises "retry the 5xx" lands on exactly the wrong behaviour:requestIdcannot protect you: idempotency is enforced against the payment record, and there isn't one. The retry is treated as a brand-new purchase and mints another credential.approxCentsderivation, not a transient blip — so the retry fails identically.The upstream hint says
DO NOT BLIND-RETRYin capitals. None of that reached a single reader.What changed
0010added to all seven hand-authored surfaces, with the "don't generalise to retry 5xx" warning stated explicitly rather than left implicit in the retryable column:skills/nevermined-router/SKILL.mdskills/nevermined-router/references/errors.mdproducts/router/guardrails.mdx<Warning>.cursor/.windsurf/.clinerules/.amazonqrule filesVerification
BCK.ROUTER.*code and HTTP status re-checked againstlibs/helpers/src/lib/output/api-errors.tson nvm-monorepoorigin/main.0001–0009all still match the docs exactly.Corrected:0010was the only missing one.BCK.ROUTER.0011(402, 3-D Secure) landed onorigin/mainthe same day this was authored, so that enumeration was true when run and stale when published. Both are now documented here — see below..cursortrap).guardrails.mdx.development-guide/api-errors/codes.mdxis deliberately not touched — it's generated and synced from nvm-monorepo, and #287 already carries0010.mintlify broken-linkscan't run — the bare<token>in that same generatedcodes.mdxaborts the parse site-wide. Pre-existing; source fix is nvm-monorepo#2805.Five more drifts found, filed separately
The same sweep turned up five further gaps between the skill and today's API. They're tracked in https://github.com/nevermined-io/nvm-monorepo/issues/2854 rather than piled in here — none is currently wrong for a live user, whereas
0010is a live hazard now.BCK.ROUTER.0011— added after review402,
category: business,retryable: false. The card issuer demands 3-D Secure and the Router has no human at a browser to complete it, so nothing was charged and the seller never received a usable credential.Why it must not be auto-retried, per its own hint: 3DS is often mandated per charge by industry rules, so every attempt re-demands it, and each attempt creates a fresh single-use card credential that is then abandoned — each expiring at
min(merchant's quoted expiry, Delegation expiry, 89 days). A later attempt may succeed, but that is a human decision, not a loop.It is also distinct from both other 402s, which the docs now say explicitly:
0003is your cap,0009is a card refused for lack of funds. Here the card is fine — it simply hasn't been authenticated for this charge.The reframing that came with it (aaitor's second point, and the better lesson): the takeaway isn't "
0010is the exception to retry the 5xx" — it's that the HTTP status doesn't tell you whether to retry.0010is a 500 you must not retry;0011is a 402 that reads like a routine payment error and equally must not be. A reader who internalises "the one status trap is the 500" walks straight into the 402 one. That is now stated inSKILL.mdrule 4 and in theguardrails.mdxWarning.