Skip to content

fix(handler): fail closed after Core 503 refusal - #67

Merged
mrnicholasbcarter-code merged 2 commits into
masterfrom
002-fail-closed-next-handler
Sep 13, 2026
Merged

fix(handler): fail closed after Core 503 refusal#67
mrnicholasbcarter-code merged 2 commits into
masterfrom
002-fail-closed-next-handler

Conversation

@mrnicholasbcarter-code

@mrnicholasbcarter-code mrnicholasbcarter-code commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes Linear BOD-86 / GitHub verdict-node#51: createNextApiHandler no longer calls proxy() after middleware writes HTTP 503 or otherwise refuses.

Hard eligibility remains fail-closed. Envelope validation uses published @bodanglin/verdict-contracts@0.2.0 because 0.1.0 has no execution_envelope contract.

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 passed
  • npm test — 223 passed
  • npm run typecheck — 0
  • npm run build — 0
  • npm run verify:package — 0

Linear

https://linear.app/bodanglin/issue/BOD-86/nodep0-fix-createnextapihandler-fail-open-after-503-denial


Devin Review

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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread src/index.ts
Comment on lines +1087 to +1097
if (this.requireCoreDecision) {
try {
enforceExecutionEnvelope(envelope, { ...requestBody, model: candidateModel }, {
required: true,
expectedPolicyDigest,
});
} catch (err) {
envelopeDenied = true;
lastError = err;
continue;
}

@devin-ai-integration devin-ai-integration Bot Sep 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/index.ts
Comment on lines +1065 to +1068
enforceExecutionEnvelope(envelope, parsedRequest.data, {
required: true,
expectedPolicyDigest,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/index.ts
Comment on lines +819 to +823
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Policy digest check trusts itself

expectedPolicyDigest comes from the same response as the envelope. Changing both values together bypasses tamper detection, so forged policy constraints pass validation.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@mrnicholasbcarter-code
mrnicholasbcarter-code merged commit 02a87db into master Sep 13, 2026
6 checks passed
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