diff --git a/apps/desktop/src/settings/DesktopClientSettings.removedKeys.test.ts b/apps/desktop/src/settings/DesktopClientSettings.removedKeys.test.ts
new file mode 100644
index 0000000000000..e1bf0fb5143aa
--- /dev/null
+++ b/apps/desktop/src/settings/DesktopClientSettings.removedKeys.test.ts
@@ -0,0 +1,93 @@
+import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, describe, it } from "@effect/vitest";
+import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+
+import * as DesktopConfig from "../app/DesktopConfig.ts";
+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
+import * as DesktopClientSettings from "./DesktopClientSettings.ts";
+
+// Fork-only. An earlier fork build saved `anchorEnvironmentId` in the client settings file.
+// The setting is gone; a file that still holds it must load (an invalid file fails the read),
+// keep the user's other choices, and lose the key on the next save.
+function layer(baseDir: string) {
+ const layerEnvironment = DesktopEnvironment.layer({
+ dirname: "/repo/apps/desktop/src",
+ homeDirectory: baseDir,
+ platform: "darwin",
+ processArch: "x64",
+ appVersion: "1.2.3",
+ appPath: "/repo",
+ isPackaged: true,
+ resourcesPath: "/missing/resources",
+ runningUnderArm64Translation: false,
+ }).pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ NodeServices.layer,
+ NodeCrypto.layer,
+ DesktopConfig.layerTest({ T3CODE_HOME: baseDir }),
+ ),
+ ),
+ );
+ return DesktopClientSettings.layer.pipe(
+ Layer.provideMerge(layerEnvironment),
+ Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
+ );
+}
+
+const withClientSettings = (
+ effect: Effect.Effect,
+) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-desktop-client-settings-removed-keys-test-",
+ });
+ return yield* effect.pipe(Effect.provide(layer(baseDir)));
+ }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped);
+
+const weights = { "environment-anchor": 100, "environment-other": 0 };
+
+describe("DesktopClientSettings saved with a removed fork key", () => {
+ it.effect.each([
+ { label: "direct", wrap: (settings: object) => settings },
+ { label: "wrapped", wrap: (settings: object) => ({ settings }) },
+ ])("loads a $label document and drops the key on the next save", ({ wrap }) =>
+ withClientSettings(
+ Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
+ yield* fileSystem.writeFileString(
+ environment.clientSettingsPath,
+ JSON.stringify(
+ wrap({
+ anchorEnvironmentId: "environment-anchor",
+ loadBalancingEnabled: true,
+ loadBalancingWeights: weights,
+ }),
+ ),
+ );
+
+ const persisted = yield* settings.get;
+ assert.isTrue(Option.isSome(persisted));
+ if (Option.isNone(persisted)) return;
+ assert.deepStrictEqual(persisted.value, {
+ ...DEFAULT_CLIENT_SETTINGS,
+ loadBalancingEnabled: true,
+ loadBalancingWeights: weights,
+ });
+
+ yield* settings.set(persisted.value);
+ const saved = yield* fileSystem.readFileString(environment.clientSettingsPath);
+ assert.notInclude(saved, "anchorEnvironmentId");
+ }),
+ ),
+ );
+});
diff --git a/apps/web/src/clientPersistenceStorage.removedKeys.test.ts b/apps/web/src/clientPersistenceStorage.removedKeys.test.ts
new file mode 100644
index 0000000000000..89460a7ab6f36
--- /dev/null
+++ b/apps/web/src/clientPersistenceStorage.removedKeys.test.ts
@@ -0,0 +1,61 @@
+import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+
+// Fork-only. An earlier fork build saved `anchorEnvironmentId` in the client settings.
+// The setting is gone; settings that still hold it must load (an invalid document throws
+// on read), keep the user's other choices, and lose the key on the next save.
+const STORAGE_KEY = "t3code:client-settings:v1";
+
+function stubWindow(): Storage {
+ const store = new Map();
+ const localStorage = {
+ getItem: (key: string) => store.get(key) ?? null,
+ setItem: (key: string, value: string) => void store.set(key, value),
+ removeItem: (key: string) => void store.delete(key),
+ clear: () => store.clear(),
+ key: (index: number) => [...store.keys()][index] ?? null,
+ get length() {
+ return store.size;
+ },
+ } satisfies Storage;
+ vi.stubGlobal("window", { localStorage });
+ vi.stubGlobal("localStorage", localStorage);
+ return localStorage;
+}
+
+afterEach(() => {
+ vi.resetModules();
+ vi.unstubAllGlobals();
+});
+
+describe("browser client settings saved with a removed fork key", () => {
+ const weights = { "environment-anchor": 100, "environment-other": 0 };
+
+ it.each([
+ ["an environment id", "environment-anchor"],
+ ["null", null],
+ ["a non-string", 42],
+ ])("loads when the key holds %s and drops it on the next save", async (_label, anchor) => {
+ const storage = stubWindow();
+ storage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ anchorEnvironmentId: anchor,
+ loadBalancingEnabled: true,
+ loadBalancingWeights: weights,
+ }),
+ );
+ const { readBrowserClientSettings, writeBrowserClientSettings } =
+ await import("./clientPersistenceStorage");
+
+ const settings = readBrowserClientSettings();
+ expect(settings).toEqual({
+ ...DEFAULT_CLIENT_SETTINGS,
+ loadBalancingEnabled: true,
+ loadBalancingWeights: weights,
+ });
+
+ writeBrowserClientSettings(settings!);
+ expect(JSON.parse(storage.getItem(STORAGE_KEY)!)).not.toHaveProperty("anchorEnvironmentId");
+ });
+});
diff --git a/docs/fork/anchor-machine.md b/docs/fork/anchor-machine.md
new file mode 100644
index 0000000000000..445b25162462a
--- /dev/null
+++ b/docs/fork/anchor-machine.md
@@ -0,0 +1,55 @@
+# One machine for new work (an "anchor"), with upstream load balancing
+
+Fork note. No fork setting is needed: upstream's load balancing already expresses "start new threads of a shared
+project on this machine". It does not cover threads without a project yet (see below).
+
+## Set it up (per client: web, desktop)
+
+1. **Settings → Environments → Load balancing**: turn on **Automatically balance load**.
+2. Set the machine that should take new work to **Prefer**.
+3. Set every other machine to **Manual only**. A machine you add later starts at **Normal** and joins automatic
+ selection, so set it to **Manual only** as well.
+
+New threads in a project that several machines share then start on the preferred machine.
+
+## Threads without a project: not covered yet
+
+Until upstream fixes its issue #31048, threads without a project ignore the **Prefer** and **Manual only** weights.
+They start on the primary machine (or the machine you start them from), even when it is set to **Manual only**. The
+composer offers no **Auto balance**, and **Send** goes to that machine without a warning. To use another machine, pick it in the composer; its "No
+project" folder opens first.
+
+The fix exists as upstream PR #31049. The fork does not carry it yet, because it conflicts with upstream code that
+is already released (upstream PR #35658). It is parked in
+[scuttletoby/t3code#43](https://github.com/scuttletoby/t3code/pull/43) until upstream updates or merges it.
+
+## What happens when (threads in a shared project)
+
+| Situation | Result |
+| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Preferred machine connected, with CPU and memory free | The draft shows **Auto balance** and moves there. |
+| Preferred machine offline, or busy (CPU at 95% or more, or 5% memory or less) | The draft stays where it opened. **Send** refuses with "Choose a machine to continue"; pick a machine in the composer. |
+| A file is attached before the draft has moved | The draft stays on the machine it opened on, and **Send** goes there, even if that machine is **Manual only** (upstream rule: attachments stay on their machine). |
+| A thread is referenced that lives on a **Prefer** or **Normal** machine | That machine is the only one that can read it, so the draft moves there. |
+| A thread is referenced that lives on a **Manual only** machine | No machine is picked. **Send** refuses with "Choose a machine to continue"; pick that machine in the composer. |
+| A thread on another machine is referenced after the draft has moved | The draft does not move again. **Send** refuses with "Attached thread is on another machine"; remove the thread or pick its machine. |
+| You pick a machine, branch or worktree by hand | That choice wins until you choose **Auto balance** again. |
+| Only one machine has the project | No balancing; the draft uses that machine. |
+| Load balancing off | Upstream's order: the primary machine first. |
+| Thread without a project | Not balanced; see above. |
+
+## After upstream fixes #31048
+
+Threads without a project will then follow the same weights. The preferred machine's "No project" folder opens
+first, then the draft moves. One case can still send to a machine you did not choose: if that open fails or is cut
+off (for example, the connection drops while it opens), the draft settles on the machine it is on. The composer
+shows **Auto balance**, and **Send** goes to that machine even if it is **Manual only**. A failed open shows "Could
+not switch machine"; a cut-off open shows nothing. Pick the machine by hand if it matters. This is upstream's
+behaviour in #31049; the fork does not change it.
+
+## Removed fork setting
+
+An earlier draft of this fork change added a client setting `anchorEnvironmentId` (**Settings → Connections →
+Anchor machine**). It was removed before it shipped. Settings saved with it still load: the key is ignored, the
+other settings stay, and the next save drops it (tests `clientPersistenceStorage.removedKeys.test.ts` and
+`DesktopClientSettings.removedKeys.test.ts`).