{"record":{"id":"935675a62240d9a6","repo":"JuliusBrussee/caveman","slug":"cave-execution-plan-selection-mismatch","errorCode":"cave_execution_plan_selection_mismatch","errorMessage":"cave_execution_plan_selection_mismatch","messagePattern":"cave_execution_plan_selection_mismatch","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/agent/src/execution-kernel.ts","lineNumber":59,"sourceCode":"  readonly plan: CavePlan;\n  readonly planSHA256: string;\n  readonly contextIRSHA256: string;\n  readonly provider: string;\n  readonly model: string;\n}\n\nexport function validatePlanSelection(\n  plan: CavePlan,\n  selected: {\n    provider: string;\n    model: string;\n    reasoning: \"off\" | \"minimal\" | \"low\" | \"medium\" | \"high\";\n  },\n): void {\n  const expectedReasoning = plan.reasoning === \"none\" ? \"off\" : plan.reasoning;\n  if (`${selected.provider}/${selected.model}` !== plan.model ||\n      selected.reasoning !== expectedReasoning) {\n    throw new Error(\"cave_execution_plan_selection_mismatch\");\n  }\n}\n\nexport function prepareLockedHarnessExecution(input: {\n  build: CaveBuildLock;\n  harness: string;\n  adapterVersion: string;\n  upstreamVersion: string;\n  agentId?: string;\n  contextIR: ContextIR;\n  plan: CavePlan;\n}): LockedHarnessPreparation {\n  const build = parseCaveBuildLock(input.build);\n  if (build.harness.id !== input.harness ||\n      build.harness.adapter_version !== input.adapterVersion ||\n      build.harness.upstream_version !== input.upstreamVersion) {\n    throw new Error(\"cave_harness_build_mismatch\");\n  }","sourceCodeStart":41,"sourceCodeEnd":77,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/766dce6b1394ebb56a3090748d5a0240a5aefb36/packages/agent/src/execution-kernel.ts#L41-L77","documentation":"validatePlanSelection asserts that the provider/model/reasoning a runtime actually selected is exactly the locked plan's selection, by comparing `${provider}/${model}` to plan.model and reasoning to plan.reasoning (with plan value \"none\" normalized to runtime value \"off\"). It is the execution kernel's guard that locked builds never silently run a different model or effort level than the compiler selected. The string comparison means provider and model identity, not just the concatenation, must match byte-for-byte.","triggerScenarios":"Passing selected.reasoning \"none\" (plan vocabulary) instead of \"off\" (runtime vocabulary); building selected from your app config's default model instead of the plan's model string; a provider or model string containing an extra slash so the concatenation shifts (e.g. model \"openrouter/anthropic/claude-...\"); validating against a different CavePlan than the one locked.","commonSituations":"Adapters wiring a user-configurable model alongside a locked build; framework upgrades renaming reasoning levels; copy-pasted selection code that hardcodes reasoning \"low\" while the plan locked \"medium\"; splitting plan.model on the wrong delimiter before recombining.","solutions":["Derive provider and model from plan.model itself (split on \"/\" once, rejoin the remainder) instead of using an independent config value","Map reasoning exactly: selected.reasoning = plan.reasoning === \"none\" ? \"off\" : plan.reasoning","If the mismatch is intentional (you want a different model), recompile the plan/build rather than bypassing the check","Log both `${provider}/${model}` and plan.model next to the two reasoning values to see which half diverged"],"exampleFix":"// before\nvalidatePlanSelection(plan, {\n  provider: config.provider,            // e.g. \"openai\"\n  model: config.model,                  // e.g. \"gpt-4o\" — may differ from plan\n  reasoning: \"low\",                     // hardcoded, plan locked \"medium\"\n});\n\n// after — derive everything from the locked plan\nconst [provider, ...modelParts] = plan.model.split(\"/\");\nvalidatePlanSelection(plan, {\n  provider,\n  model: modelParts.join(\"/\"),\n  reasoning: plan.reasoning === \"none\" ? \"off\" : plan.reasoning,\n});","handlingStrategy":"validation","validationCode":"import type { CavePlan } from \"@caveman-ai/agent\";\n\nfunction selectionFromPlan(plan: CavePlan): {\n  provider: string;\n  model: string;\n  reasoning: \"off\" | \"minimal\" | \"low\" | \"medium\" | \"high\";\n} {\n  const [provider, ...modelParts] = plan.model.split(\"/\");\n  return {\n    provider,\n    model: modelParts.join(\"/\"),\n    reasoning: plan.reasoning === \"none\" ? \"off\" : plan.reasoning,\n  };\n}\n\n// construct `selected` exclusively via selectionFromPlan(plan) — never from app config","typeGuard":"const isRuntimeReasoning = (value: unknown): value is \"off\" | \"minimal\" | \"low\" | \"medium\" | \"high\" =>\n  [\"off\", \"minimal\", \"low\", \"medium\", \"high\"].includes(value as string);","tryCatchPattern":"try {\n  validatePlanSelection(plan, selected);\n} catch (error) {\n  if (error instanceof Error && error.message === \"cave_execution_plan_selection_mismatch\") {\n    const expectedReasoning = plan.reasoning === \"none\" ? \"off\" : plan.reasoning;\n    throw new Error(\n      `runtime selected ${selected.provider}/${selected.model}/${selected.reasoning} ` +\n      `but plan locked ${plan.model}/${expectedReasoning}`,\n      { cause: error },\n    );\n  }\n  throw error;\n}","preventionTips":["Always derive provider/model by splitting plan.model, and map reasoning \"none\" -> \"off\"","Never source the selected model from app/user config when executing a locked plan","Recompile the plan when you want a different selection instead of bypassing the check","Unit-test the selection mapping whenever the framework's reasoning vocabulary changes"],"tags":["execution-kernel","locked-build","model-selection","reasoning","integrity"],"backgroundTag":"selected-model-mismatch","analyzedSha":"766dce6b1394ebb56a3090748d5a0240a5aefb36","analyzedAt":"2026-08-18T03:14:35.516Z","contentChangedAt":"2026-08-18T03:14:35.516Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}