fix(discord): configure gateway ready timeouts

This commit is contained in:
Peter Steinberger
2026-05-02 03:15:34 +01:00
parent 60538f3369
commit 3bdaa1ceca
12 changed files with 223 additions and 35 deletions

View File

@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
- Discord/DMs: keep no-guild inbound messages on direct-message routing when Discord channel lookup is temporarily unavailable, preventing degraded DMs from forking into channel sessions. Fixes #59817. Thanks @DooPeePey.
- Discord: retry outbound API calls on HTTP 5xx, request-timeout, and transient transport failures instead of only Discord rate limits, reducing dropped cron and agent replies during short Discord or network outages. Fixes #52396. Thanks @sunshineo.
- Discord: include Components v2 Text Display content from referenced replies and forwarded snapshots, so component-only messages still appear in reply context. Fixes #56228. Thanks @HollandDrive.
- Discord: add configurable gateway READY timeouts for startup and runtime reconnects, so staggered multi-account setups can avoid false restart loops. Fixes #72273. Thanks @sergionsantos.
- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.
- Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu.

View File

@@ -1,4 +1,4 @@
8bbb620e445cba64aa8a451cfc1a7142ac24e8c80088d74a2fc813ee9e221680 config-baseline.json
d145a87759d16d5f58873db337a25cb134ab25e776cd454812dca99bb9cb12a7 config-baseline.core.json
c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json
7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json
1d9157a39ad18841d666af90c58e0539d6427cbd2ad0c1ce29047a5a2131ba7e config-baseline.json
80e6e8dce647aef2d1310de55a81d27de52cca47fc24bd7ad81b80f43a72b84c config-baseline.core.json
1cec599c3d27c258b9df3446baa547cb164e502afa9b30c052bba8737183f551 config-baseline.channel.json
8346667910d2b3a3884efce8f96591adebc4f7ea99ce18337b80e4d70bf8e4d2 config-baseline.plugin.json

View File

@@ -1255,6 +1255,22 @@ openclaw logs --follow
</Accordion>
<Accordion title="Gateway READY timeout restarts">
OpenClaw waits for Discord's gateway `READY` event during startup and after runtime reconnects. Multi-account setups with startup staggering can need a longer startup READY window than the default.
READY timeout knobs:
- startup single-account: `channels.discord.gatewayReadyTimeoutMs`
- startup multi-account: `channels.discord.accounts.<accountId>.gatewayReadyTimeoutMs`
- startup env fallback when config is unset: `OPENCLAW_DISCORD_READY_TIMEOUT_MS`
- startup default: `15000` (15 seconds), max: `120000`
- runtime single-account: `channels.discord.gatewayRuntimeReadyTimeoutMs`
- runtime multi-account: `channels.discord.accounts.<accountId>.gatewayRuntimeReadyTimeoutMs`
- runtime env fallback when config is unset: `OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS`
- runtime default: `30000` (30 seconds), max: `120000`
</Accordion>
<Accordion title="Permissions audit mismatches">
`channels status --probe` permission checks only work for numeric channel IDs.
@@ -1301,7 +1317,7 @@ Primary reference: [Configuration reference - Discord](/gateway/config-channels#
- policy: `groupPolicy`, `dm.*`, `guilds.*`, `guilds.*.channels.*`
- command: `commands.native`, `commands.useAccessGroups`, `configWrites`, `slashCommand.*`
- event queue: `eventQueue.listenerTimeout` (listener budget), `eventQueue.maxQueueSize`, `eventQueue.maxConcurrency`
- gateway metadata: `gatewayInfoTimeoutMs`
- gateway: `gatewayInfoTimeoutMs`, `gatewayReadyTimeoutMs`, `gatewayRuntimeReadyTimeoutMs`
- reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage`
- streaming: `streaming` (legacy alias: `streamMode`), `streaming.preview.toolProgress`, `draftChunk`, `blockStreaming`, `blockStreamingCoalesce`

View File

@@ -168,6 +168,33 @@ describe("discordPlugin outbound", () => {
expect(resolveReplyToMode({ cfg, accountId: "default" })).toBe("all");
});
it("inherits Discord gateway READY timeout settings per account", () => {
const cfg = {
channels: {
discord: {
token: "discord-token",
gatewayReadyTimeoutMs: 90_000,
gatewayRuntimeReadyTimeoutMs: 120_000,
accounts: {
work: {
token: "discord-token-work",
gatewayReadyTimeoutMs: 60_000,
},
},
},
},
} as OpenClawConfig;
expect(resolveAccount(cfg).config).toMatchObject({
gatewayReadyTimeoutMs: 90_000,
gatewayRuntimeReadyTimeoutMs: 120_000,
});
expect(resolveAccount(cfg, "work").config).toMatchObject({
gatewayReadyTimeoutMs: 60_000,
gatewayRuntimeReadyTimeoutMs: 120_000,
});
});
it("forwards full media send context to sendMessageDiscord", async () => {
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
const mediaReadFile = vi.fn(async () => Buffer.from("media"));

View File

@@ -141,6 +141,14 @@ export const discordChannelConfigUiHints = {
label: "Discord Gateway Metadata Timeout (ms)",
help: "Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset.",
},
gatewayReadyTimeoutMs: {
label: "Discord Gateway READY Timeout (ms)",
help: "Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset.",
},
gatewayRuntimeReadyTimeoutMs: {
label: "Discord Gateway Runtime READY Timeout (ms)",
help: "Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset.",
},
"voice.enabled": {
label: "Discord Voice Enabled",
help: "Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent.",

View File

@@ -59,9 +59,15 @@ vi.mock("./gateway-registry.js", () => ({
describe("runDiscordGatewayLifecycle", () => {
let runDiscordGatewayLifecycle: typeof import("./provider.lifecycle.js").runDiscordGatewayLifecycle;
let resolveDiscordGatewayReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayReadyTimeoutMs;
let resolveDiscordGatewayRuntimeReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayRuntimeReadyTimeoutMs;
beforeAll(async () => {
({ runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"));
({
runDiscordGatewayLifecycle,
resolveDiscordGatewayReadyTimeoutMs,
resolveDiscordGatewayRuntimeReadyTimeoutMs,
} = await import("./provider.lifecycle.js"));
});
beforeEach(() => {
@@ -143,24 +149,25 @@ describe("runDiscordGatewayLifecycle", () => {
error: runtimeError,
exit: vi.fn(),
};
const lifecycleParams: LifecycleParams = {
accountId: "default",
gateway: gateway ? (gateway as unknown as MutableDiscordGateway) : undefined,
runtime,
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
voiceManager: null,
voiceManagerRef: { current: null },
threadBindings: { stop: threadStop },
gatewaySupervisor,
statusSink,
abortSignal: undefined,
};
return {
threadStop,
runtimeLog,
runtimeError,
gatewaySupervisor,
statusSink,
lifecycleParams: {
accountId: "default",
gateway: gateway ? (gateway as unknown as MutableDiscordGateway) : undefined,
runtime,
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
voiceManager: null,
voiceManagerRef: { current: null },
threadBindings: { stop: threadStop },
gatewaySupervisor,
statusSink,
abortSignal: undefined as AbortSignal | undefined,
} satisfies LifecycleParams,
lifecycleParams,
};
}
@@ -176,6 +183,26 @@ describe("runDiscordGatewayLifecycle", () => {
expect(params.gatewaySupervisor.detachLifecycle).toHaveBeenCalledTimes(1);
}
it("resolves gateway READY timeouts from config, env, then defaults", () => {
expect(resolveDiscordGatewayReadyTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000);
expect(
resolveDiscordGatewayReadyTimeoutMs({
env: { OPENCLAW_DISCORD_READY_TIMEOUT_MS: "90000" },
}),
).toBe(90_000);
expect(resolveDiscordGatewayReadyTimeoutMs({ env: {} })).toBe(15_000);
expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ configuredTimeoutMs: 60_000 })).toBe(
60_000,
);
expect(
resolveDiscordGatewayRuntimeReadyTimeoutMs({
env: { OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS: "120000" },
}),
).toBe(120_000);
expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ env: {} })).toBe(30_000);
});
it("cleans up thread bindings when gateway wait fails before READY", async () => {
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("startup failed"));
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness();
@@ -228,14 +255,15 @@ describe("runDiscordGatewayLifecycle", () => {
gateway: null,
},
);
lifecycleParams.gatewayReadyTimeoutMs = 5_000;
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
lifecyclePromise.catch(() => {});
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(15_500);
await vi.advanceTimersByTimeAsync(5_500);
await expect(lifecyclePromise).rejects.toThrow(
"discord gateway did not reach READY within 15000ms",
"discord gateway did not reach READY within 5000ms",
);
expect(statusSink).not.toHaveBeenCalledWith(
expect.objectContaining({
@@ -606,15 +634,16 @@ describe("runDiscordGatewayLifecycle", () => {
);
const { lifecycleParams, runtimeError, statusSink } = createLifecycleHarness({ gateway });
lifecycleParams.gatewayRuntimeReadyTimeoutMs = 5_000;
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
lifecyclePromise.catch(() => {});
await vi.advanceTimersByTimeAsync(30_500);
await vi.advanceTimersByTimeAsync(5_500);
await expect(lifecyclePromise).rejects.toThrow(
"discord gateway opened but did not reach READY within 30000ms",
"discord gateway opened but did not reach READY within 5000ms",
);
expect(runtimeError).toHaveBeenCalledWith(
expect.stringContaining("did not reach READY within 30000ms"),
expect.stringContaining("did not reach READY within 5000ms"),
);
expect(statusSink).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -19,8 +19,11 @@ import {
} from "./gateway-supervisor.js";
import type { DiscordMonitorStatusSink } from "./status.js";
const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000;
const DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS = 30_000;
const DEFAULT_DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000;
const DEFAULT_DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS = 30_000;
const MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS = 120_000;
const DISCORD_GATEWAY_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_READY_TIMEOUT_MS";
const DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS";
const DISCORD_GATEWAY_READY_POLL_MS = 250;
const DISCORD_GATEWAY_STARTUP_DISCONNECT_DRAIN_TIMEOUT_MS = 5_000;
const DISCORD_GATEWAY_STARTUP_TERMINATE_CLOSE_TIMEOUT_MS = 1_000;
@@ -28,6 +31,37 @@ const DISCORD_GATEWAY_TRANSPORT_ACTIVITY_STATUS_MIN_INTERVAL_MS = 30_000;
type GatewayReadyWaitResult = "ready" | "stopped" | "timeout";
function normalizeGatewayReadyTimeoutMs(value: unknown): number | undefined {
const numeric =
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
if (!Number.isFinite(numeric) || numeric <= 0) {
return undefined;
}
return Math.min(Math.floor(numeric), MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS);
}
export function resolveDiscordGatewayReadyTimeoutMs(params?: {
configuredTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): number {
return (
normalizeGatewayReadyTimeoutMs(params?.configuredTimeoutMs) ??
normalizeGatewayReadyTimeoutMs(params?.env?.[DISCORD_GATEWAY_READY_TIMEOUT_ENV]) ??
DEFAULT_DISCORD_GATEWAY_READY_TIMEOUT_MS
);
}
export function resolveDiscordGatewayRuntimeReadyTimeoutMs(params?: {
configuredTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): number {
return (
normalizeGatewayReadyTimeoutMs(params?.configuredTimeoutMs) ??
normalizeGatewayReadyTimeoutMs(params?.env?.[DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_ENV]) ??
DEFAULT_DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS
);
}
async function restartGatewayAfterReadyTimeout(params: {
gateway?: Pick<MutableDiscordGateway, "connect" | "disconnect" | "ws">;
abortSignal?: AbortSignal;
@@ -158,6 +192,7 @@ function createGatewayStatusObserver(params: {
runtime: RuntimeEnv;
pushStatus: (patch: Parameters<DiscordMonitorStatusSink>[0]) => void;
isLifecycleStopping: () => boolean;
runtimeReadyTimeoutMs: number;
}) {
let forceStopHandler: ((err: unknown) => void) | undefined;
let queuedForceStopError: unknown;
@@ -214,7 +249,7 @@ function createGatewayStatusObserver(params: {
}
const at = Date.now();
const error = new Error(
`discord gateway opened but did not reach READY within ${DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS}ms`,
`discord gateway opened but did not reach READY within ${params.runtimeReadyTimeoutMs}ms`,
);
params.pushStatus({
connected: false,
@@ -227,7 +262,7 @@ function createGatewayStatusObserver(params: {
});
params.runtime.error?.(danger(error.message));
triggerForceStop(error);
}, DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS);
}, params.runtimeReadyTimeoutMs);
readyTimeoutId.unref?.();
}
};
@@ -292,9 +327,10 @@ async function waitForGatewayReady(params: {
pushStatus?: (patch: Parameters<DiscordMonitorStatusSink>[0]) => void;
runtime: RuntimeEnv;
beforeRestart?: () => Promise<void> | void;
readyTimeoutMs: number;
}): Promise<void> {
const waitUntilReady = async (): Promise<GatewayReadyWaitResult> => {
const deadlineAt = Date.now() + DISCORD_GATEWAY_READY_TIMEOUT_MS;
const deadlineAt = Date.now() + params.readyTimeoutMs;
while (!params.abortSignal?.aborted) {
if ((await params.beforePoll?.()) === "stop") {
return "stopped";
@@ -324,16 +360,12 @@ async function waitForGatewayReady(params: {
return;
}
if (!params.gateway) {
throw new Error(
`discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms`,
);
throw new Error(`discord gateway did not reach READY within ${params.readyTimeoutMs}ms`);
}
const restartAt = Date.now();
params.runtime.error?.(
danger(
`discord: gateway was not ready after ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms; restarting gateway`,
),
danger(`discord: gateway was not ready after ${params.readyTimeoutMs}ms; restarting gateway`),
);
params.pushStatus?.({
connected: false,
@@ -356,7 +388,7 @@ async function waitForGatewayReady(params: {
if ((await waitUntilReady()) === "timeout") {
throw new Error(
`discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms after restart`,
`discord gateway did not reach READY within ${params.readyTimeoutMs}ms after restart`,
);
}
}
@@ -372,6 +404,8 @@ export async function runDiscordGatewayLifecycle(params: {
threadBindings: { stop: () => void };
gatewaySupervisor: DiscordGatewaySupervisor;
statusSink?: DiscordMonitorStatusSink;
gatewayReadyTimeoutMs?: number;
gatewayRuntimeReadyTimeoutMs?: number;
}) {
const gateway = params.gateway;
if (gateway) {
@@ -387,12 +421,21 @@ export async function runDiscordGatewayLifecycle(params: {
const pushStatus = (patch: Parameters<DiscordMonitorStatusSink>[0]) => {
params.statusSink?.(patch);
};
const gatewayReadyTimeoutMs = resolveDiscordGatewayReadyTimeoutMs({
configuredTimeoutMs: params.gatewayReadyTimeoutMs,
env: process.env,
});
const gatewayRuntimeReadyTimeoutMs = resolveDiscordGatewayRuntimeReadyTimeoutMs({
configuredTimeoutMs: params.gatewayRuntimeReadyTimeoutMs,
env: process.env,
});
const statusObserver = createGatewayStatusObserver({
gateway,
abortSignal: params.abortSignal,
runtime: params.runtime,
pushStatus,
isLifecycleStopping: () => lifecycleStopping,
runtimeReadyTimeoutMs: gatewayRuntimeReadyTimeoutMs,
});
gatewayEmitter?.on("debug", statusObserver.onGatewayDebug);
let lastTransportActivityStatusAt: number | undefined;
@@ -460,6 +503,7 @@ export async function runDiscordGatewayLifecycle(params: {
pushStatus,
runtime: params.runtime,
beforeRestart: statusObserver.clearReadyWatch,
readyTimeoutMs: gatewayReadyTimeoutMs,
});
if (drainPendingGatewayErrors() === "stop") {

View File

@@ -382,6 +382,33 @@ describe("monitorDiscordProvider", () => {
expect(reconcileAcpThreadBindingsOnStartupMock).toHaveBeenCalledTimes(1);
});
it("passes configured gateway READY timeouts to the lifecycle monitor", async () => {
resolveDiscordAccountMock.mockReturnValueOnce({
accountId: "default",
token: "cfg-token",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: { enabled: false },
gatewayReadyTimeoutMs: 90_000,
gatewayRuntimeReadyTimeoutMs: 120_000,
},
});
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
});
expect(monitorLifecycleMock).toHaveBeenCalledWith(
expect.objectContaining({
gatewayReadyTimeoutMs: 90_000,
gatewayRuntimeReadyTimeoutMs: 120_000,
}),
);
});
it("does not load the Discord voice runtime when voice is disabled", async () => {
await monitorDiscordProvider({
config: baseConfig(),

View File

@@ -626,6 +626,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
voiceManagerRef,
threadBindings,
gatewaySupervisor,
gatewayReadyTimeoutMs: account.config.gatewayReadyTimeoutMs,
gatewayRuntimeReadyTimeoutMs: account.config.gatewayRuntimeReadyTimeoutMs,
});
} finally {
cleanupDiscordProviderStartup({

View File

@@ -806,6 +806,16 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 120000,
},
gatewayReadyTimeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 120000,
},
gatewayRuntimeReadyTimeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 120000,
},
allowBots: {
anyOf: [
{
@@ -2182,6 +2192,16 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 120000,
},
gatewayReadyTimeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 120000,
},
gatewayRuntimeReadyTimeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 120000,
},
allowBots: {
anyOf: [
{
@@ -3573,6 +3593,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Discord Gateway Metadata Timeout (ms)",
help: "Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset.",
},
gatewayReadyTimeoutMs: {
label: "Discord Gateway READY Timeout (ms)",
help: "Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset.",
},
gatewayRuntimeReadyTimeoutMs: {
label: "Discord Gateway Runtime READY Timeout (ms)",
help: "Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset.",
},
"voice.enabled": {
label: "Discord Voice Enabled",
help: "Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent.",

View File

@@ -251,6 +251,10 @@ export type DiscordAccountConfig = {
proxy?: string;
/** Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default: 30000. */
gatewayInfoTimeoutMs?: number;
/** Startup wait for the gateway READY event before restarting the socket. Default: 15000. */
gatewayReadyTimeoutMs?: number;
/** Runtime reconnect wait for the gateway READY event before force-stopping the lifecycle. Default: 30000. */
gatewayRuntimeReadyTimeoutMs?: number;
/** Allow bot-authored messages to trigger replies (default: false). Set "mentions" to gate on mentions. */
allowBots?: boolean | "mentions";
/**

View File

@@ -532,6 +532,8 @@ export const DiscordAccountSchema = z
applicationId: DiscordIdSchema.optional(),
proxy: z.string().optional(),
gatewayInfoTimeoutMs: z.number().int().positive().max(120_000).optional(),
gatewayReadyTimeoutMs: z.number().int().positive().max(120_000).optional(),
gatewayRuntimeReadyTimeoutMs: z.number().int().positive().max(120_000).optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),