diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index eb8ad6004..938ac99ac 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -10290,8 +10290,6 @@ components:
type: boolean
mcp:
type: boolean
- inboundDocuments:
- type: boolean
mentalHealth:
type: boolean
additionalProperties: false
diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx
index c15c72723..c552c0245 100644
--- a/src/components/admin/module-availability-section.tsx
+++ b/src/components/admin/module-availability-section.tsx
@@ -7,7 +7,11 @@ import { toast } from "sonner";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
-import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry";
+import {
+ MODULE_KEYS,
+ MODULE_REGISTRY,
+ isCodeDisabledModule,
+} from "@/lib/modules/registry";
import type { ModuleKey } from "@/lib/modules/registry";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
@@ -90,24 +94,30 @@ export function ModuleAvailabilitySection() {
description={t("admin.modules.description")}
/>
- {MODULE_KEYS.map((key) => {
- const def = MODULE_REGISTRY[key];
- // Default-available until the read resolves; an explicit `false`
- // from the operator layer is the only thing that turns it off.
- const available = availability?.[key] ?? true;
- return (
-
- mutation.mutate({ [key]: checked } as Partial)
- }
- disabled={mutation.isPending}
- />
- );
- })}
+ {MODULE_KEYS
+ // A module switched off in code (pending a rebuild) is hard-off
+ // server-wide; drop its row so the operator toggle can't mislead.
+ .filter((key) => !isCodeDisabledModule(key))
+ .map((key) => {
+ const def = MODULE_REGISTRY[key];
+ // Default-available until the read resolves; an explicit `false`
+ // from the operator layer is the only thing that turns it off.
+ const available = availability?.[key] ?? true;
+ return (
+
+ mutation.mutate({
+ [key]: checked,
+ } as Partial)
+ }
+ disabled={mutation.isPending}
+ />
+ );
+ })}
);
diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx
index dbd2822da..db37c4881 100644
--- a/src/components/settings/__tests__/modules-section.test.tsx
+++ b/src/components/settings/__tests__/modules-section.test.tsx
@@ -20,7 +20,11 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
-import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
+import {
+ MODULE_KEYS,
+ isCodeDisabledModule,
+ moduleDelegatesTo,
+} from "@/lib/modules/registry";
// Capture the latest useMutation config + a shared mutate spy so we can
// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a
@@ -112,7 +116,13 @@ describe("", () => {
// only renders the toggleable modules.
const html = render();
for (const key of MODULE_KEYS) {
- if (moduleDelegatesTo(key) !== undefined) {
+ if (isCodeDisabledModule(key)) {
+ // v1.25.3 — a module switched off in code (pending a rebuild) drops
+ // out of the hub entirely: no Switch, no deep-link, no row.
+ expect(html, `code-disabled has no switch ${key}`).not.toContain(
+ `id="module-toggle-${key}"`,
+ );
+ } else if (moduleDelegatesTo(key) !== undefined) {
// Delegated modules (cycle/coach) are owned by their real control
// elsewhere — they render as a read-only "manage in X" deep-link,
// never a live Switch that would write an inert disabled-allowlist
diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx
index e67da7e3f..d38419b5b 100644
--- a/src/components/settings/modules-section.tsx
+++ b/src/components/settings/modules-section.tsx
@@ -55,6 +55,7 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
MODULE_REGISTRY,
+ isCodeDisabledModule,
moduleDelegatesTo,
type ModuleKey,
} from "@/lib/modules/registry";
@@ -130,48 +131,52 @@ export function ModulesSection() {
{t("settings.sections.modules.toggleable.description")}
- {(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
- const def = MODULE_REGISTRY[key];
- // Default-on: a module is enabled unless explicitly `false`.
- const enabled = modules[key] !== false;
- // Delegated modules (cycle/coach) are owned elsewhere — never a
- // live toggle here; deep-link to their real control instead.
- const delegated = moduleDelegatesTo(key) !== undefined;
- // Operator turned this module off server-wide: no per-user
- // toggle can re-enable it, so show a read-only note. Delegated
- // rows already deep-link, so this only matters for owned keys.
- const operatorDisabled =
- !delegated && moduleAvailability[key] === false;
- return (
- toggle.mutate({ key, enabled: next })}
- />
- );
- })}
+ {(Object.keys(MODULE_REGISTRY) as ModuleKey[])
+ // Modules switched off in code (pending a rebuild) carry no live
+ // toggle — drop the row entirely so a user can't turn one on.
+ .filter((key) => !isCodeDisabledModule(key))
+ .map((key) => {
+ const def = MODULE_REGISTRY[key];
+ // Default-on: a module is enabled unless explicitly `false`.
+ const enabled = modules[key] !== false;
+ // Delegated modules (cycle/coach) are owned elsewhere — never a
+ // live toggle here; deep-link to their real control instead.
+ const delegated = moduleDelegatesTo(key) !== undefined;
+ // Operator turned this module off server-wide: no per-user
+ // toggle can re-enable it, so show a read-only note. Delegated
+ // rows already deep-link, so this only matters for owned keys.
+ const operatorDisabled =
+ !delegated && moduleAvailability[key] === false;
+ return (
+ toggle.mutate({ key, enabled: next })}
+ />
+ );
+ })}
);
diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts
index 730bf8b87..8785629eb 100644
--- a/src/lib/modules/__tests__/gate.test.ts
+++ b/src/lib/modules/__tests__/gate.test.ts
@@ -20,6 +20,7 @@ import {
} from "../operator-availability";
import {
isCoreDomain,
+ isCodeDisabledModule,
isModuleKey,
isOptInModule,
moduleDelegatesTo,
@@ -373,14 +374,22 @@ describe("registry — opt-in marker", () => {
});
});
-describe("resolveModuleEnabled — inboundDocuments module (opt-in, default-OFF)", () => {
+describe("resolveModuleEnabled — inboundDocuments module (disabled in code)", () => {
+ // v1.25.3 — `inboundDocuments` is switched off in code pending a rebuild
+ // (`CODE_DISABLED_MODULE_KEYS`). It is hard-off ahead of both the operator
+ // and the per-user layer: no stored preference or availability blob can
+ // re-surface it. The registry entry + `optIn` marker stay intact.
+ it("is flagged as code-disabled in the registry", () => {
+ expect(isCodeDisabledModule("inboundDocuments")).toBe(true);
+ });
+
it("is OFF when no preference is recorded", () => {
expect(
resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE),
).toBe(false);
});
- it("turns ON only on an explicit true (opt-in)", () => {
+ it("stays OFF even when the user opted in (the in-code switch wins)", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
@@ -388,16 +397,16 @@ describe("resolveModuleEnabled — inboundDocuments module (opt-in, default-OFF)
false,
ALL_AVAILABLE,
),
- ).toBe(true);
+ ).toBe(false);
});
- it("operator-off short-circuits even when the user opted in", () => {
+ it("stays OFF when the user opted in and the operator left it available", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs({ modulePreferences: { inboundDocuments: true } }),
false,
- operator({ inboundDocuments: false }),
+ operator({ inboundDocuments: true }),
),
).toBe(false);
});
diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts
index ff32f383a..61bb9bf04 100644
--- a/src/lib/modules/gate.ts
+++ b/src/lib/modules/gate.ts
@@ -55,6 +55,7 @@ import {
} from "./operator-availability";
import {
MODULE_KEYS,
+ isCodeDisabledModule,
isOptInModule,
moduleDelegatesTo,
type ModuleKey,
@@ -126,6 +127,14 @@ export function resolveModuleEnabled(
assistantCoach: boolean,
operatorAvailability: OperatorModuleAvailability,
): boolean {
+ // LAYER 0 — switched off in code pending a rebuild. Hard-off for every
+ // account, ahead of both the operator and the per-user layer, so no
+ // stored preference or availability blob can re-surface it. See
+ // `CODE_DISABLED_MODULE_KEYS` in ./registry.
+ if (isCodeDisabledModule(key)) {
+ return false;
+ }
+
// LAYER 1 — operator server-wide availability. An operator-disabled
// module is off for everyone regardless of personal preference.
if (operatorAvailability[key] === false) {
diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts
index 2af5a9636..97d422b38 100644
--- a/src/lib/modules/registry.ts
+++ b/src/lib/modules/registry.ts
@@ -327,6 +327,37 @@ export function isModuleKey(key: string): key is ModuleKey {
return MODULE_KEY_SET.has(key);
}
+/**
+ * Modules switched OFF in code, pending a holistic rebuild.
+ *
+ * A key listed here is hard-off for every account regardless of the
+ * per-user opt-in or the operator-availability layer: the gate short-
+ * circuits it to disabled (`resolveModuleEnabled`), the Modules hub and the
+ * operator-availability panel drop its row, and the per-user PATCH surface
+ * refuses to write it. The whole implementation (routes, components, schema,
+ * migration) stays intact — re-enabling the module is a one-line removal
+ * from this list once the rebuild lands.
+ *
+ * v1.25.3 — `inboundDocuments` is parked here: the inbound-documents surface
+ * is withdrawn from this release and returns later with more depth. Its
+ * registry entry, `optIn` marker, routes, view, and migration 0222 are all
+ * preserved; only the user-reachable switches are removed.
+ */
+export const CODE_DISABLED_MODULE_KEYS = ["inboundDocuments"] as const;
+
+const CODE_DISABLED_MODULE_SET: ReadonlySet = new Set(
+ CODE_DISABLED_MODULE_KEYS,
+);
+
+/**
+ * True for a module intentionally switched off in code pending a rebuild
+ * (see `CODE_DISABLED_MODULE_KEYS`). Such a module is hard-off everywhere:
+ * the gate never reports it enabled and the user-facing toggles drop it.
+ */
+export function isCodeDisabledModule(key: ModuleKey): boolean {
+ return CODE_DISABLED_MODULE_SET.has(key);
+}
+
/** The two delegated keys, resolved by their existing source of truth. */
export function moduleDelegatesTo(
key: ModuleKey,
diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts
index 05c041870..7b9189e29 100644
--- a/src/lib/validations/modules.ts
+++ b/src/lib/validations/modules.ts
@@ -25,14 +25,21 @@
*/
import { z } from "zod/v4";
-import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
+import {
+ MODULE_KEYS,
+ isCodeDisabledModule,
+ moduleDelegatesTo,
+} from "@/lib/modules/registry";
/**
* The directly-owned (non-delegated) toggleable keys — the only keys this
- * PATCH surface may write into `modulePreferencesJson`.
+ * PATCH surface may write into `modulePreferencesJson`. A module switched
+ * off in code (pending a rebuild) is also excluded, so a client trying to
+ * opt into it gets a clean 422 rather than persisting an inert `true` the
+ * gate would ignore anyway.
*/
const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
- (k) => moduleDelegatesTo(k) === undefined,
+ (k) => moduleDelegatesTo(k) === undefined && !isCodeDisabledModule(k),
);
/** Build `{ ?: boolean }` from the directly-owned key list. */