fix(handler): fail closed after Core 503 refusal - #67
Conversation
Stop nextApiHandler from calling proxy after middleware denial. Add refusal/bypass regression tests and bump contracts to 0.2.0 so envelope validation can fail closed. Keep README disclosure until merge.
There was a problem hiding this comment.
Devin Review found 3 potential issues.
2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| if (this.requireCoreDecision) { | ||
| try { | ||
| enforceExecutionEnvelope(envelope, { ...requestBody, model: candidateModel }, { | ||
| required: true, | ||
| expectedPolicyDigest, | ||
| }); | ||
| } catch (err) { | ||
| envelopeDenied = true; | ||
| lastError = err; | ||
| continue; | ||
| } |
There was a problem hiding this comment.
🔴 Core-selected route is never attempted
When Core authorizes a model absent from buildDynamicLadder, proxy never attempts that selected route. Every local candidate then fails envelope validation, returning 403 for a valid allow.
Learn more
The middleware stores Core's selected model in req.llmRouter.decision.model, but buildDynamicLadder only returns locally discovered models plus primaryModel. The added candidate validation can therefore reject the entire local ladder without ever testing the route Core selected. This affects valid decisions whenever the selected model is absent from discovery, maps to a different local tier, or differs from the configured primary model.
Example: Core selects openai/gpt-4o-mini, and the envelope allows only that model. If model discovery returns no models, the tier-3 ladder contains only cc/claude-opus-4-8. Envelope validation skips that candidate and the handler returns 403 instead of forwarding to openai/gpt-4o-mini.
Recommended fix: Seed the forwarding ladder from req.llmRouter.decision.model on the policy-gated path. Only add fallback candidates authorized by the envelope, preserving Core's selected route as the first attempt.
Was this helpful? React with 👍 or 👎 to provide feedback.
| enforceExecutionEnvelope(envelope, parsedRequest.data, { | ||
| required: true, | ||
| expectedPolicyDigest, | ||
| }); |
There was a problem hiding this comment.
🔴 Valid Core reroutes are rejected
When Core reroutes a request, enforceExecutionEnvelope rejects the original model before evaluating the authorized candidate. Valid reroutes return 403 without reaching upstream.
Learn more
The high-level gateway replaces the request model with each route candidate before forwarding. The first envelope check instead validates parsedRequest.data, which still contains the client-requested model. An envelope authorizing Core's selected output model can legitimately exclude that original alias or model, so this check rejects before the candidate-specific validation runs.
Example: A client requests auto, Core selects openai/gpt-4o-mini, and the envelope permits only openai/gpt-4o-mini. The first check reports model_disallowed; the later check of the actual outgoing model never runs.
Recommended fix: Perform the pre-ladder check without treating the original model as the actual route, then enforce model constraints on every concrete candidate. Alternatively validate Core's selected model at this stage and retain the per-candidate checks for fallbacks.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const digest = (canonical as Record<string, unknown>).policy_digest; | ||
| req.llmRouter = { | ||
| decision: { ...adapted, latencyMs: Date.now() - start }, | ||
| executionEnvelope: envelope, | ||
| expectedPolicyDigest: typeof digest === 'string' ? digest : undefined, |
There was a problem hiding this comment.
Summary
Fixes Linear BOD-86 / GitHub verdict-node#51:
createNextApiHandlerno longer callsproxy()after middleware writes HTTP 503 or otherwise refuses.Hard eligibility remains fail-closed. Envelope validation uses published
@bodanglin/verdict-contracts@0.2.0because 0.1.0 has noexecution_envelopecontract.README critical-limitation disclosure is left in place until this PR is merged and main is verified green.
Proof
npm test -- --runInBand tests/router.test.ts— 118 passednpm test— 223 passednpm run typecheck— 0npm run build— 0npm run verify:package— 0Linear
https://linear.app/bodanglin/issue/BOD-86/nodep0-fix-createnextapihandler-fail-open-after-503-denial