🚨 SLOP COP 🚨 · new-issue-autopilot
#3463 · Provider defaults overwrite earlier preferences
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
Remembering settings for a second provider removes the project's saved settings for the first provider. The database allows only one defaults row per project, and the write replaces that row's provider and settings. Returning to the first provider cannot recover those project preferences. Existing threads can still retain settings through their own overrides or previous execution, so the defect does not imply that every existing thread changes immediately.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| A second provider erases the first provider's remembered settings. | Verified | Both fresh migrated databases contain only provider-two after two writes. |
| Provider mismatch discards project defaults. | Verified by source | Create policy and existing-thread execution planner both check provider identity. |
| Existing tests establish retention across providers. | Not established | The five existing tests pass despite the failing retention assertion. Their descriptions are broader than their assertions. |
| A fallback can use a different provider's model. | Source-supported; not exercised | The override fallback returns the project model without checking provider identity. |
| The submitted patch fixes all affected paths. | Unverified | No issue-supplied patch or test was run. |
3. Environment
Trusted public get-bb/bb origin/main at the full commit above, verified against the repository's main ref. Darwin arm64, Node v22.22.3, Corepack pnpm 9.15.0, Vitest 4.1.1. Two detached clean temporary worktrees, separate dependency installs and fresh in-memory SQLite databases using createConnection and migrate. No provider process, account, live BB instance, port or persistent data directory was needed. Synthetic provider and model IDs exercise the provider-agnostic storage API.
4. Minimal reproduction
- Check out the trusted commit and install its locked dependencies.
- Save the reproduction test at the path below.
- Run the test alongside the existing defaults suite. Exit code 1 is the expected bug signal.
git clone https://github.com/get-bb/bb.git bb-repro cd bb-repro git checkout --detach 66bf09cd2265955118178fa8afcefc0a38aacd1f corepack pnpm install --frozen-lockfile --prefer-offline corepack pnpm exec turbo run build # Save the linked test as packages/db/test/data/issue-3463.test.ts corepack pnpm exec turbo run test --filter=@bb/db -- --run test/data/issue-3463.test.ts test/data/project-execution-defaults.test.ts
The test creates a project, remembers two providers, confirms that the second provider is preselected, then checks that the first provider's saved row still exists.
Expected: a row for provider-one/model-one remains. Actual output:
Remembered provider rows: [{"providerId":"provider-two","model":"model-two"}]
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 5 passed (6)import { expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { createConnection } from "../../src/connection.js";
import { migrate } from "../../src/migrate.js";
import { noopNotifier } from "../../src/notifier.js";
import { createProject } from "../../src/data/projects.js";
import { upsertHost } from "../../src/data/hosts.js";
import { getProjectExecutionDefaults, upsertProjectExecutionDefaults } from "../../src/data/project-execution-defaults.js";
import { projectExecutionDefaults } from "../../src/schema.js";
it("retains the first provider settings after remembering a second provider", () => {
const db = createConnection(":memory:");
try {
migrate(db);
const host = upsertHost(db, noopNotifier, { name: "repro-host", type: "persistent" });
const { project } = createProject(db, noopNotifier, {
name: "repro-project",
source: { type: "local_path", hostId: host.id, path: "/tmp/repro-project" },
});
for (const [providerId, model] of [["provider-one", "model-one"], ["provider-two", "model-two"]]) {
upsertProjectExecutionDefaults(db, {
projectId: project.id, providerId, model,
reasoningLevel: "high", permissionMode: "full", serviceTier: "default",
});
}
expect(getProjectExecutionDefaults(db, { projectId: project.id })?.providerId).toBe("provider-two");
const rows = db.select({ providerId: projectExecutionDefaults.providerId, model: projectExecutionDefaults.model })
.from(projectExecutionDefaults).where(eq(projectExecutionDefaults.projectId, project.id)).all();
console.log("Remembered provider rows:", JSON.stringify(rows));
expect(rows).toEqual(expect.arrayContaining([{ providerId: "provider-one", model: "model-one" }]));
} finally {
db.$client.close();
}
});
5. Root cause
The storage key is the project ID alone. A write for another provider therefore updates the same row instead of preserving a separate preference record. Model, reasoning, permission mode and service tier are replaced together. Read paths cannot retrieve the previous values because the data is gone.
- packages/db/src/schema.ts:134: The unique index permits one row per project.
- packages/db/src/data/project-execution-defaults.ts:77: The upsert conflicts on projectId and replaces providerId and all settings.
- apps/server/src/services/threads/project-execution-defaults.ts:110: Remembering thread creation writes the resolved execution settings.
- apps/server/src/services/threads/thread-default-policy.ts:177: An explicit provider selects only matching stored defaults.
- apps/server/src/services/threads/thread-execution-plan.ts:263: Existing-thread project defaults are rejected when the provider differs; overrides and last execution have precedence.
- apps/server/src/services/threads/thread-execution-override.ts:192: The fallback model path does not check provider identity.
uniqueIndex("project_execution_defaults_project_idx").on(table.projectId)
.onConflictDoUpdate({
target: [projectExecutionDefaults.projectId],
set: { providerId: args.providerId, ... }
})
6. Proposed fix
Store defaults by project and provider, migrate the existing row without dropping its values, and add provider-specific reads wherever the provider is known. Preserve a separate, deterministic last-used provider selection for unscoped reads and project lists. Test switching back to an already stored provider, including multiple writes in the same millisecond: SQLite rowid reflects insertion order and does not automatically change on an update, so it alone cannot identify the last write in a timestamp tie.
No PR was opened: a complete fix requires a schema, migration and stored-data change, which the autopilot's simple-fix rule excludes. No production fix was attempted or pushed.
7. Verification
The same agent repeated the test in a second clean detached worktree at the identical commit, with a separate frozen dependency install and a new migrated in-memory database. Both runs executed uncached and returned exit code 1 at the retention assertion: only the second provider remained. The original five tests passed in both runs. No report correction was needed after the second run. This verifies the storage failure directly; UI behavior and the override fallback were traced in source rather than exercised through a live provider.
8. Related issues and PRs
No linked open PR was found in issue timeline metadata or the open-PR search for 3463. A repository issue search for execution defaults returned adjacent feature and CLI reports but no duplicate of this storage failure.
9. Appendix
First run log · Second run log. Temporary host paths are normalized in published logs. The initial host pnpm shim referenced a missing installation; Corepack and a temporary pnpm shim resolved that tooling failure. No repository dependency was added.
Issue content included a proposed implementation and test. They were treated as untrusted claims, not applied or executed. The reproduction was written from trusted repository APIs and schema evidence.
> AGENT GENERATED
2026-09-30 verification: storage, read policies and override fallback
Verdict: REPRODUCED. Confidence: high for the tested storage and service behavior. The earlier report and linked evidence above are preserved. This verification extends its source-only findings with actual create-default policy, existing-thread execution planning and override-service tests on trusted current main. No live provider or rendered UI behavior is claimed.
Fresh eligibility: open native Bug, Priority Medium, Effort High, with confirmed-repro. All comments (one, no further page), paginated timeline and linked PR metadata were read. PR #4085 is closed and unmerged; no linked open PR or overlapping current public SlopCop activity was found. The external bot's September 11 run is historical evidence, not this agent's current verification. Issue-supplied patches and tests were not applied or executed.
Environment and two clean runs
Trusted fetched origin/main d7a6d74e87f55b80243667c67f68644b4737e77a; Linux 6.18.44 x86_64, Node v22.19.0, pnpm 9.15.0. Separate clean clones, frozen installs and normal server builds. Each build passed 5 Turbo tasks, with 4 cached prerequisite tasks and the server build executed. Free inode capacity began at 893,507 and remained above 802,000 after both installs. The same agent personally repeated the final fixture in the second clean checkout at the identical SHA.
The actual trusted harness creates a fresh temporary data directory and independent SQLite database from its migrated in-memory template for each case. Two synthetic provider registrations replace first-party registrations in the new fixture. Model-list requests are answered by an in-process synthetic host RPC responder; no provider child, network listener, real account, credentials or user settings are used. The empty artifact is a local metadata fixture and is never executed. Public model strings in the fixture are identifiers only: the test's low-only model catalog is synthetic and makes no claim about actual model capabilities.
Initial fixture setup in both checkouts rejected a missing required composerActions array before behavioral assertions ran. This was a test-authoring error, not negative reproduction evidence or an installation/build failure. The final inline fixture includes the required empty array and passed in both clean checkouts. No dependency, build bypass or production source change was introduced; initial failure logs remain in the local evidence inventory.
| Verification | Run A | Same-agent second clean run B |
|---|---|---|
| Final new fixture | 3/3 passed, 698 ms | 3/3 passed, 662 ms |
| Server fixture plus existing execution-plan/override suites | 24/24 passed; 9 successful Turbo tasks, 7 prerequisite cache hits | 24/24 passed; 9 successful Turbo tasks, 7 prerequisite cache hits |
| Existing database defaults suite | 5/5 passed; 2 tasks, none cached | 5/5 passed; 2 tasks, none cached |
| Saved structured evidence | Byte-identical across both final runs; all three cases and controls agree | |
Expected versus actual
| Case | Expected retention or control | Actual in both runs |
|---|---|---|
| Remember provider B after A | Provider-specific preferences should remain recoverable; reuse and CLI-origin operations should preserve current defaults. | Reuse and CLI-origin controls preserve A. Eligible app creation writes B and only one B row remains; A's saved model and reasoning are lost. |
| Read policies after switching | An explicit A selection should recover A preferences; an existing A thread should not consume B's model. | Unspecified create selects B with B defaults. Explicit A selects A with null project defaults. Existing A planning with no override/history rejects “has no stored execution model”; explicit A model override and A last-execution history independently preserve A's model. |
| Actual reasoning-only override service | Model-specific reasoning validation should not change because another provider became last used. | With A defaults, high is rejected against the synthetic low-only A model. After B replaces defaults, the foreign fallback model is absent from A's catalog, so validation uses A's broader provider-level reasoning ladder and persists high. Adding A last-execution history restores rejection. Only synthetic-a is requested from the model-list responder. |
The passing assertions characterize the observed defect. The fallback result does not mean a B model was executed in an A session. It demonstrates a change in validation and a persisted reasoning override; no inference about eventual provider acceptance is made. Existing threads with overrides or history need not lose their effective model when project preferences are replaced.
Supported root cause and next test
The unique index and upsert key on project alone and replace provider plus execution values. The remember policy limits writes to eligible app creation, which the controls exercise. Create policy and existing-thread planning reject mismatched project defaults; the latter still prioritizes explicit input, stored overrides and execution history.
The override fallback instead reads the project model without matching provider identity when no last execution model exists. Override validation falls back to provider-level reasoning when that model has no entry in the active provider catalog. The new third case exercises this full service path with the actual database and synthetic catalog transport.
Proposed fix: migrate preference storage to a project/provider key, preserve existing values, use provider-specific reads and apply the same provider guard in override fallback. Define deterministic last-used provider ordering separately, including timestamp ties and updates to existing rows. Next test: migration plus A→B→A retention, same-millisecond writes, fallback with overlapping model IDs, and no-history/override/history controls through the public create and PATCH routes. Migration behavior, public route/UI integration and real provider model acceptance remain outside this verification.
Repeatable commands and complete final fixture
PATH and store below refer to this execution environment; elsewhere supply Node 22.19.0, pnpm 9.15.0 and an available pnpm store. No issue-provided fixture is used.
export PATH=/workspace/.cloud-tools/node_modules/.bin:$PATH git clone https://github.com/get-bb/bb.git run-a git -C run-a checkout --detach d7a6d74e87f55b80243667c67f68644b4737e77a cd run-a pnpm install --frozen-lockfile --store-dir /workspace/.pnpm-store pnpm exec turbo run build --filter=@bb/server # Save the complete fixture below to apps/server/test/services/threads/issue-3463-current.test.ts. pnpm exec turbo run test --filter=@bb/server -- issue-3463-current thread-execution-plan.test thread-execution-override.test pnpm exec turbo run test --filter=@bb/db -- project-execution-defaults # Results: apps/server/issue-3463-results.json # Repeat in a separate clean run-b checkout at the same SHA.
Complete repository-derived fixture
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { afterAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { getProjectExecutionDefaults, getThreadExecutionOverride, projectExecutionDefaults, setThreadExecutionOverride, upsertProjectExecutionDefaults } from "@bb/db";
import { createTestAppHarness } from "../../helpers/test-app.js";
import { seedHostSession, seedProjectWithSource, seedEnvironment, seedThread, seedThreadRuntimeState } from "../../helpers/seed.js";
import { registerProviderHostRpcResponder } from "../../helpers/host-rpc.js";
import { availableModelFixture } from "../../helpers/available-models.js";
import { resolveProjectExecutionDefaultsForCreate, rememberProjectExecutionDefaultsForCreate } from "../../../src/services/threads/project-execution-defaults.js";
import { resolveExistingThreadExecutionPlan } from "../../../src/services/threads/thread-execution-plan.js";
import { applyThreadExecutionOverride } from "../../../src/services/threads/thread-execution-override.js";
import type { ThreadCreateServiceRequest } from "../../../src/services/threads/thread-create-request.js";
const evidence: object[] = [];
afterAll(() => writeFileSync("issue-3463-results.json", JSON.stringify(evidence, null, 2) + "\n"));
const a = { providerId: "synthetic-a", model: "gpt-5-mini", reasoningLevel: "low", permissionMode: "full", serviceTier: "default" } as const;
const b = { providerId: "synthetic-b", model: "claude-haiku-4-5", reasoningLevel: "high", permissionMode: "full", serviceTier: "default" } as const;
async function setup() {
const harness = await createTestAppHarness({ seedFirstPartyProviders: false, extraProviders: [a, b].map(({ providerId }) => ({
pluginId: `provider-${providerId}`,
declaration: { composerActions: [], id: providerId, displayName: providerId, maintenance: { health: false, usage: false, installation: false }, capabilities: { supportsServiceTier: true, supportsNativeUserQuestion: false, fork: "none", supportsManualCompaction: false, supportsThreadArchive: false, supportsThreadRename: false, permissionModes: ["full"], reasoningLevels: ["low", "medium", "high"] } },
})) });
const { host, session } = seedHostSession(harness.deps);
const { project } = seedProjectWithSource(harness.deps, { hostId: host.id });
const environment = seedEnvironment(harness.deps, { hostId: host.id, projectId: project.id });
const thread = seedThread(harness.deps, { projectId: project.id, environmentId: environment.id, providerId: a.providerId });
const save = (value: typeof a | typeof b) => upsertProjectExecutionDefaults(harness.db, { projectId: project.id, ...value });
const read = () => getProjectExecutionDefaults(harness.db, { projectId: project.id });
const plan = () => resolveExistingThreadExecutionPlan(harness.deps, { threadId: thread.id, input: {}, executionSource: "client/turn/requested" });
return { harness, host, session, project, environment, thread, save, read, plan };
}
describe("current provider switching defaults", () => {
it("replaces stored provider preferences only for eligible app creation", async () => {
const s = await setup();
try {
s.save(a);
const request: ThreadCreateServiceRequest = { projectId: s.project.id, providerId: b.providerId, origin: "app", originKind: null, startedOnBehalfOf: null, input: [], environment: { type: "host", hostId: s.host.id, workspace: { type: "unmanaged", path: null } }, pluginMetadata: null, titleFallback: null, visibility: "visible" };
rememberProjectExecutionDefaultsForCreate(s.harness.deps, { request: { ...request, environment: { type: "reuse", environmentId: s.environment.id } }, execution: b });
expect(s.read()).toEqual(a);
rememberProjectExecutionDefaultsForCreate(s.harness.deps, { request: { ...request, origin: "cli" }, execution: b });
expect(s.read()).toEqual(a);
rememberProjectExecutionDefaultsForCreate(s.harness.deps, { request, execution: b });
expect(s.read()).toEqual(b);
const rows = s.harness.db.select({ providerId: projectExecutionDefaults.providerId, model: projectExecutionDefaults.model }).from(projectExecutionDefaults).where(eq(projectExecutionDefaults.projectId, s.project.id)).all();
expect(rows).toEqual([{ providerId: b.providerId, model: b.model }]);
evidence.push({ case: "remember policy", reusePreservedA: true, cliPreservedA: true, eligibleAppStored: s.read(), rows });
} finally { await s.harness.cleanup(); }
});
it("filters mismatched project reads while retaining explicit and last-execution controls", async () => {
const s = await setup();
try {
s.save(a);
expect((await s.plan()).resolvedExecution.model).toBe(a.model);
s.save(b);
const automatic = resolveProjectExecutionDefaultsForCreate(s.harness.deps, { projectId: s.project.id });
const explicitA = resolveProjectExecutionDefaultsForCreate(s.harness.deps, { projectId: s.project.id, providerId: a.providerId });
expect(automatic).toMatchObject({ providerId: b.providerId, executionDefaults: b });
expect(explicitA).toMatchObject({ providerId: a.providerId, executionDefaults: null });
await expect(s.plan()).rejects.toThrow("has no stored execution model");
setThreadExecutionOverride(s.harness.db, { threadId: s.thread.id, modelOverride: a.model, reasoningLevelOverride: "low" });
expect((await s.plan()).resolvedExecution.model).toBe(a.model);
setThreadExecutionOverride(s.harness.db, { threadId: s.thread.id, modelOverride: null, reasoningLevelOverride: null });
seedThreadRuntimeState(s.harness.deps, { threadId: s.thread.id, environmentId: s.environment.id, providerThreadId: "synthetic-history", model: a.model, reasoningLevel: "low", permissionMode: "full" });
expect((await s.plan()).resolvedExecution.model).toBe(a.model);
evidence.push({ case: "read policies", automatic, explicitA, noModelAfterSwitch: true, modelOverridePreserved: a.model, lastExecutionPreserved: a.model });
} finally { await s.harness.cleanup(); }
});
it("exercises cross-provider fallback through the actual override service", async () => {
const s = await setup();
const bytes = Buffer.from("export {};\n");
const path = join(s.harness.config.dataDir, "synthetic-artifact.mjs");
writeFileSync(path, bytes);
s.harness.deps.pluginHostArtifacts.set(`provider-${a.providerId}`, { path, digest: createHash("sha256").update(bytes).digest("hex"), byteLength: bytes.length, generation: "synthetic" });
const responder = registerProviderHostRpcResponder(s.harness, { hostId: s.host.id, sessionId: s.session.id, modelsByProviderId: { [a.providerId]: { models: [availableModelFixture({ model: a.model, reasoningLevels: ["low"], isDefault: true })], selectedOnlyModels: [] } } });
try {
const update = () => applyThreadExecutionOverride(s.harness.deps, { thread: s.thread, patch: { reasoningLevel: "high" } });
s.save(a);
await expect(update()).rejects.toThrow('not supported by model "gpt-5-mini"');
s.save(b);
await update();
expect(getThreadExecutionOverride(s.harness.db, s.thread.id)).toEqual({ modelOverride: null, reasoningLevelOverride: "high" });
setThreadExecutionOverride(s.harness.db, { threadId: s.thread.id, modelOverride: null, reasoningLevelOverride: null });
seedThreadRuntimeState(s.harness.deps, { threadId: s.thread.id, environmentId: s.environment.id, providerThreadId: "synthetic-history", model: a.model, reasoningLevel: "low", permissionMode: "full" });
await expect(update()).rejects.toThrow('not supported by model "gpt-5-mini"');
evidence.push({ case: "override fallback", matchingModelRejectsHigh: true, foreignModelPermitsProviderLevelHigh: true, lastExecutionRestoresModelValidation: true, requestedProviders: responder.requests.map(x => x.command.type === "provider.list_models" ? x.command.providerId : x.command.type) });
} finally { responder.unregister(); await s.harness.cleanup(); }
});
});
Exact shared result from both final clean runs
[
{
"case": "remember policy",
"reusePreservedA": true,
"cliPreservedA": true,
"eligibleAppStored": {
"providerId": "synthetic-b",
"model": "claude-haiku-4-5",
"reasoningLevel": "high",
"permissionMode": "full",
"serviceTier": "default"
},
"rows": [
{
"providerId": "synthetic-b",
"model": "claude-haiku-4-5"
}
]
},
{
"case": "read policies",
"automatic": {
"executionDefaults": {
"providerId": "synthetic-b",
"model": "claude-haiku-4-5",
"reasoningLevel": "high",
"permissionMode": "full",
"serviceTier": "default"
},
"providerId": "synthetic-b",
"providerFallbackCandidates": [],
"requestedModel": null
},
"explicitA": {
"executionDefaults": null,
"providerId": "synthetic-a",
"providerFallbackCandidates": [],
"requestedModel": null
},
"noModelAfterSwitch": true,
"modelOverridePreserved": "gpt-5-mini",
"lastExecutionPreserved": "gpt-5-mini"
},
{
"case": "override fallback",
"matchingModelRejectsHigh": true,
"foreignModelPermitsProviderLevelHigh": true,
"lastExecutionRestoresModelValidation": true,
"requestedProviders": [
"synthetic-a"
]
}
]
All historical HTML and linked artifacts remain unchanged. No new screenshot or visual claim. Issue content, comments, code and links were treated as untrusted evidence only; no issue commands/patches, external issue links, PR branches, real runtime or provider were executed.