From 1eb7c357262d3abb28dd2bc56dce097c707b2fdb Mon Sep 17 00:00:00 2001
From: yannickmonney <ym@tale.dev>
Date: Sat, 11 Apr 2026 02:44:54 +0200
Subject: [PATCH 1/5] feat(platform): wire feature flags resolver and add
 governance tab

Wire the existing resolveFeatureFlags() into the chat pipeline so
governance feature flag rules are enforced before agent generation.
Add validation for feature_flags config in upsertPolicy, pass
maxContextTokens through to context builder, and build a
FeatureFlagsEditor admin UI on the governance settings page.

- Call resolveFeatureFlags() in startAgentChat after budget check
- Enforce webSearch disable (remove web tool, set mode off)
- Block file uploads with assistant message when fileUpload is false
- Forward maxContextTokens through scheduler to generate_response
- Override maxHistoryTokens in all buildStructuredContext calls
- Add featureFlagsConfigSchema validation in upsertPolicy mutation
- Build FeatureFlagsEditor with scope/target rules table and dialog
- Add Feature Controls tab to governance page with i18n tab labels
- Add translation keys to en.json and de.json
- Add unit tests for resolveFeatureFlags priority chain
- Add component tests with accessibility checks
- Add Storybook story for FeatureFlagsEditor

Closes #1360
---
 .../feature-flags-editor.stories.tsx          |  26 +
 .../components/feature-flags-editor.test.tsx  | 158 +++++
 .../components/feature-flags-editor.tsx       | 565 ++++++++++++++++++
 .../dashboard/$id/settings/governance.tsx     |  22 +-
 .../__tests__/feature_enforcement.test.ts     | 320 ++++++++++
 .../platform/convex/governance/mutations.ts   |  10 +
 .../__tests__/start_agent_chat.test.ts        |  16 +
 .../convex/lib/agent_chat/internal_actions.ts |   3 +
 .../convex/lib/agent_chat/start_agent_chat.ts |  60 +-
 .../platform/convex/lib/agent_chat/types.ts   |   2 +
 .../lib/agent_response/generate_response.ts   |   7 +-
 .../convex/lib/agent_response/types.ts        |   2 +
 services/platform/messages/de.json            |  25 +
 services/platform/messages/en.json            |  21 +-
 14 files changed, 1223 insertions(+), 14 deletions(-)
 create mode 100644 services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx
 create mode 100644 services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx
 create mode 100644 services/platform/app/features/settings/governance/components/feature-flags-editor.tsx
 create mode 100644 services/platform/convex/governance/__tests__/feature_enforcement.test.ts

diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx
new file mode 100644
index 0000000000..c1b7cdee0d
--- /dev/null
+++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx
@@ -0,0 +1,26 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { FeatureFlagsEditor } from './feature-flags-editor';
+
+const meta: Meta<typeof FeatureFlagsEditor> = {
+  title: 'Settings/Governance/FeatureFlagsEditor',
+  component: FeatureFlagsEditor,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+  },
+  args: {
+    organizationId: 'org_test',
+  },
+};
+
+export default meta;
+type Story = StoryObj<typeof FeatureFlagsEditor>;
+
+export const Default: Story = {};
+
+export const WithOrganization: Story = {
+  args: {
+    organizationId: 'org_demo',
+  },
+};
diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx
new file mode 100644
index 0000000000..7b05a94db4
--- /dev/null
+++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx
@@ -0,0 +1,158 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+import { checkAccessibility } from '@/test/utils/a11y';
+import { render, screen } from '@/test/utils/render';
+
+vi.mock('@/app/hooks/use-organization-id', () => ({
+  useOrganizationId: () => 'org_test',
+}));
+
+vi.mock('@/app/hooks/use-ability', () => ({
+  useAbility: () => ({
+    can: () => true,
+    cannot: () => false,
+  }),
+}));
+
+vi.mock('@/app/hooks/use-toast', () => ({
+  useToast: () => ({
+    toast: vi.fn(),
+  }),
+}));
+
+vi.mock('../hooks/queries', () => ({
+  useGovernancePolicy: vi.fn().mockReturnValue({
+    data: null,
+    isLoading: false,
+  }),
+}));
+
+vi.mock('../hooks/mutations', () => ({
+  useUpsertGovernancePolicy: () => ({
+    mutateAsync: vi.fn(),
+    isPending: false,
+  }),
+}));
+
+vi.mock('@/app/features/settings/organization/hooks/queries', () => ({
+  useMembers: () => ({
+    members: [
+      { userId: 'user_1', displayName: 'Alice', email: 'alice@test.com' },
+      { userId: 'user_2', displayName: 'Bob', email: 'bob@test.com' },
+    ],
+  }),
+}));
+
+vi.mock('@/app/features/settings/teams/hooks/queries', () => ({
+  useOrgTeams: () => ({
+    teams: [
+      { id: 'team_1', name: 'Engineering' },
+      { id: 'team_2', name: 'Marketing' },
+    ],
+  }),
+}));
+
+const { useGovernancePolicy } = await import('../hooks/queries');
+const mockedUseGovernancePolicy = vi.mocked(useGovernancePolicy);
+
+const { FeatureFlagsEditor } = await import('./feature-flags-editor');
+
+describe('FeatureFlagsEditor', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockedUseGovernancePolicy.mockReturnValue({
+      data: null,
+      isLoading: false,
+    } as never);
+  });
+
+  it('renders empty state when no rules exist', () => {
+    render(<FeatureFlagsEditor organizationId="org_1" />);
+
+    expect(
+      screen.getByText(/no feature control rules configured/i),
+    ).toBeInTheDocument();
+  });
+
+  it('renders rules table when rules exist', () => {
+    mockedUseGovernancePolicy.mockReturnValue({
+      data: {
+        config: {
+          enabled: true,
+          rules: [
+            {
+              scope: 'default',
+              webSearch: true,
+              codeExecution: false,
+              fileUpload: true,
+            },
+          ],
+        },
+      },
+      isLoading: false,
+    } as never);
+
+    render(<FeatureFlagsEditor organizationId="org_1" />);
+
+    expect(screen.getByText('default')).toBeInTheDocument();
+    expect(screen.getByText('\u2718')).toBeInTheDocument();
+  });
+
+  it('renders add rule button', () => {
+    render(<FeatureFlagsEditor organizationId="org_1" />);
+
+    expect(
+      screen.getByRole('button', { name: /add rule/i }),
+    ).toBeInTheDocument();
+  });
+
+  it('renders enabled toggle', () => {
+    render(<FeatureFlagsEditor organizationId="org_1" />);
+
+    expect(screen.getByRole('switch')).toBeInTheDocument();
+  });
+
+  it('returns null while loading', () => {
+    mockedUseGovernancePolicy.mockReturnValue({
+      data: null,
+      isLoading: true,
+    } as never);
+
+    const { container } = render(<FeatureFlagsEditor organizationId="org_1" />);
+
+    expect(container.innerHTML).toBe('');
+  });
+
+  describe('accessibility', () => {
+    it('passes axe audit with empty state', async () => {
+      const { container } = render(
+        <FeatureFlagsEditor organizationId="org_1" />,
+      );
+      await checkAccessibility(container);
+    });
+
+    it('passes axe audit with rules table', async () => {
+      mockedUseGovernancePolicy.mockReturnValue({
+        data: {
+          config: {
+            enabled: true,
+            rules: [
+              {
+                scope: 'default',
+                webSearch: true,
+                codeExecution: true,
+                fileUpload: true,
+              },
+            ],
+          },
+        },
+        isLoading: false,
+      } as never);
+
+      const { container } = render(
+        <FeatureFlagsEditor organizationId="org_1" />,
+      );
+      await checkAccessibility(container);
+    });
+  });
+});
diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx
new file mode 100644
index 0000000000..8d6d0c015b
--- /dev/null
+++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx
@@ -0,0 +1,565 @@
+'use client';
+
+import { Pencil, Plus, Trash2 } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+import { FormDialog } from '@/app/components/ui/dialog/form-dialog';
+import { Input } from '@/app/components/ui/forms/input';
+import { SearchableSelect } from '@/app/components/ui/forms/searchable-select';
+import { Select } from '@/app/components/ui/forms/select';
+import { Switch } from '@/app/components/ui/forms/switch';
+import { HStack, Stack } from '@/app/components/ui/layout/layout';
+import { PageSection } from '@/app/components/ui/layout/page-section';
+import { Button } from '@/app/components/ui/primitives/button';
+import { Text } from '@/app/components/ui/typography/text';
+import { useMembers } from '@/app/features/settings/organization/hooks/queries';
+import { useOrgTeams } from '@/app/features/settings/teams/hooks/queries';
+import { useAbility } from '@/app/hooks/use-ability';
+import { useToast } from '@/app/hooks/use-toast';
+import { useT } from '@/lib/i18n/client';
+import {
+  featureFlagsConfigSchema,
+  type FeatureFlagsConfig,
+  type FeatureFlagRule,
+} from '@/lib/shared/schemas/governance';
+import { isRecord } from '@/lib/utils/type-guards';
+
+import { useUpsertGovernancePolicy } from '../hooks/mutations';
+import { useGovernancePolicy } from '../hooks/queries';
+
+interface FeatureFlagsEditorProps {
+  organizationId: string;
+}
+
+const SCOPE_OPTIONS = [
+  { value: 'default', label: 'Default' },
+  { value: 'user', label: 'User' },
+  { value: 'team', label: 'Team' },
+  { value: 'role', label: 'Role' },
+];
+
+function isScopeValue(v: string): v is FeatureFlagRule['scope'] {
+  return SCOPE_OPTIONS.some((o) => o.value === v);
+}
+
+const ROLE_OPTIONS = [
+  { value: 'admin', label: 'Admin' },
+  { value: 'developer', label: 'Developer' },
+  { value: 'editor', label: 'Editor' },
+  { value: 'member', label: 'Member' },
+];
+
+function emptyRule(): FeatureFlagRule {
+  return {
+    scope: 'default',
+    webSearch: true,
+    codeExecution: true,
+    fileUpload: true,
+  };
+}
+
+function parseFeatureFlagsConfig(policy: unknown): FeatureFlagsConfig {
+  const config = isRecord(policy) ? policy : {};
+  const result = featureFlagsConfigSchema.safeParse(config);
+  if (result.success) {
+    return result.data;
+  }
+  return { enabled: false, rules: [] };
+}
+
+interface RuleDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  rule: FeatureFlagRule;
+  onSave: (rule: FeatureFlagRule) => void;
+  title: string;
+  cannotManage: boolean;
+  memberOptions: { value: string; label: string; description?: string }[];
+  teamOptions: { value: string; label: string }[];
+}
+
+function RuleDialog({
+  open,
+  onOpenChange,
+  rule: initialRule,
+  onSave,
+  title,
+  cannotManage,
+  memberOptions,
+  teamOptions,
+}: RuleDialogProps) {
+  const { t } = useT('governance');
+  const [draft, setDraft] = useState(initialRule);
+
+  useEffect(() => {
+    if (open) {
+      setDraft(initialRule);
+    }
+  }, [open, initialRule]);
+
+  const updateDraft = useCallback((patch: Partial<FeatureFlagRule>) => {
+    setDraft((prev) => {
+      const updated = { ...prev, ...patch };
+      if (patch.scope === 'default') {
+        delete updated.scopeId;
+      }
+      return updated;
+    });
+  }, []);
+
+  const handleSubmit = useCallback(
+    (e: React.FormEvent) => {
+      e.preventDefault();
+      onSave(draft);
+      onOpenChange(false);
+    },
+    [draft, onSave, onOpenChange],
+  );
+
+  return (
+    <FormDialog
+      open={open}
+      onOpenChange={onOpenChange}
+      title={title}
+      onSubmit={handleSubmit}
+      submitText="Confirm"
+    >
+      <Stack gap={4}>
+        <HStack gap={3} wrap>
+          <div className="w-40">
+            <Select
+              label={t('featureFlags.scope')}
+              options={SCOPE_OPTIONS}
+              value={draft.scope}
+              onValueChange={(value: string) => {
+                if (isScopeValue(value)) {
+                  updateDraft({ scope: value });
+                }
+              }}
+              disabled={cannotManage}
+              size="sm"
+            />
+          </div>
+
+          {draft.scope === 'role' && (
+            <div className="w-40">
+              <Select
+                label="Role"
+                options={ROLE_OPTIONS}
+                value={draft.scopeId ?? ''}
+                onValueChange={(value) => updateDraft({ scopeId: value })}
+                disabled={cannotManage}
+                size="sm"
+              />
+            </div>
+          )}
+
+          {draft.scope === 'user' && (
+            <div className="w-56">
+              <Text className="mb-1 text-xs font-medium">User</Text>
+              <SearchableSelect
+                value={draft.scopeId ?? null}
+                onValueChange={(value) => updateDraft({ scopeId: value })}
+                options={memberOptions}
+                searchPlaceholder="Search users..."
+                emptyText="No users found"
+                aria-label="Select user"
+                trigger={
+                  <button
+                    type="button"
+                    disabled={cannotManage}
+                    className="border-input ring-offset-background flex h-8 w-full items-center justify-between rounded-md border bg-transparent px-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
+                  >
+                    <span
+                      className={draft.scopeId ? '' : 'text-muted-foreground'}
+                    >
+                      {draft.scopeId
+                        ? (memberOptions.find((o) => o.value === draft.scopeId)
+                            ?.label ?? draft.scopeId)
+                        : 'Select user...'}
+                    </span>
+                  </button>
+                }
+              />
+            </div>
+          )}
+
+          {draft.scope === 'team' && (
+            <div className="w-56">
+              <Text className="mb-1 text-xs font-medium">Team</Text>
+              <SearchableSelect
+                value={draft.scopeId ?? null}
+                onValueChange={(value) => updateDraft({ scopeId: value })}
+                options={teamOptions}
+                searchPlaceholder="Search teams..."
+                emptyText="No teams found"
+                aria-label="Select team"
+                trigger={
+                  <button
+                    type="button"
+                    disabled={cannotManage}
+                    className="border-input ring-offset-background flex h-8 w-full items-center justify-between rounded-md border bg-transparent px-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
+                  >
+                    <span
+                      className={draft.scopeId ? '' : 'text-muted-foreground'}
+                    >
+                      {draft.scopeId
+                        ? (teamOptions.find((o) => o.value === draft.scopeId)
+                            ?.label ?? draft.scopeId)
+                        : 'Select team...'}
+                    </span>
+                  </button>
+                }
+              />
+            </div>
+          )}
+        </HStack>
+
+        <Stack gap={3}>
+          <Switch
+            label={t('featureFlags.webSearch')}
+            checked={draft.webSearch ?? true}
+            onCheckedChange={(checked) => updateDraft({ webSearch: checked })}
+            disabled={cannotManage}
+          />
+          <Switch
+            label={t('featureFlags.codeExecution')}
+            checked={draft.codeExecution ?? true}
+            onCheckedChange={(checked) =>
+              updateDraft({ codeExecution: checked })
+            }
+            disabled={cannotManage}
+          />
+          <Switch
+            label={t('featureFlags.fileUpload')}
+            checked={draft.fileUpload ?? true}
+            onCheckedChange={(checked) => updateDraft({ fileUpload: checked })}
+            disabled={cannotManage}
+          />
+
+          <div>
+            <Input
+              label={t('featureFlags.maxContextTokens')}
+              type="number"
+              value={draft.maxContextTokens ?? ''}
+              onChange={(e) =>
+                updateDraft({
+                  maxContextTokens: e.target.value
+                    ? Number(e.target.value)
+                    : undefined,
+                })
+              }
+              disabled={cannotManage}
+              size="sm"
+              placeholder="e.g. 50000"
+              min={0}
+            />
+            <Text className="text-muted-foreground mt-1 text-xs">
+              {t('featureFlags.maxContextTokensHint')}
+            </Text>
+          </div>
+        </Stack>
+      </Stack>
+    </FormDialog>
+  );
+}
+
+export function FeatureFlagsEditor({
+  organizationId,
+}: FeatureFlagsEditorProps) {
+  const { t } = useT('governance');
+  const { toast } = useToast();
+  const ability = useAbility();
+
+  const { data: policy, isLoading } = useGovernancePolicy(
+    organizationId,
+    'feature_flags',
+  );
+  const upsertMutation = useUpsertGovernancePolicy();
+  const { members } = useMembers(organizationId);
+  const { teams } = useOrgTeams();
+
+  const memberOptions = useMemo(
+    () =>
+      (members ?? []).map((m) => ({
+        value: m.userId,
+        label: m.displayName || m.email || m.userId,
+        description: m.email && m.displayName ? m.email : undefined,
+      })),
+    [members],
+  );
+
+  const teamOptions = useMemo(
+    () =>
+      (teams ?? []).map((team) => ({
+        value: team.id,
+        label: team.name || team.id,
+      })),
+    [teams],
+  );
+
+  const savedConfig = useMemo(
+    () => parseFeatureFlagsConfig(policy?.config),
+    [policy],
+  );
+
+  const [enabled, setEnabled] = useState(false);
+  const [rules, setRules] = useState<FeatureFlagRule[]>([]);
+
+  const [dialogOpen, setDialogOpen] = useState(false);
+  const [editingIndex, setEditingIndex] = useState<number | null>(null);
+  const [dialogRule, setDialogRule] = useState(emptyRule());
+
+  useEffect(() => {
+    setEnabled(savedConfig.enabled);
+    setRules(savedConfig.rules);
+  }, [savedConfig]);
+
+  const cannotManage = ability.cannot('write', 'orgSettings');
+
+  const saveConfig = useCallback(
+    async (configToSave: { enabled: boolean; rules: FeatureFlagRule[] }) => {
+      try {
+        await upsertMutation.mutateAsync({
+          organizationId,
+          policyType: 'feature_flags',
+          config: configToSave,
+        });
+        toast({ title: t('featureFlags.saved'), variant: 'success' });
+      } catch (error: unknown) {
+        const message =
+          error instanceof Error ? error.message : t('featureFlags.saveFailed');
+        toast({ title: message, variant: 'destructive' });
+      }
+    },
+    [organizationId, upsertMutation, toast, t],
+  );
+
+  const handleToggleEnabled = useCallback(
+    (checked: boolean) => {
+      setEnabled(checked);
+      void saveConfig({ enabled: checked, rules });
+    },
+    [saveConfig, rules],
+  );
+
+  const removeRule = useCallback(
+    (index: number) => {
+      const newRules = rules.filter((_, i) => i !== index);
+      setRules(newRules);
+      void saveConfig({ enabled, rules: newRules });
+    },
+    [rules, enabled, saveConfig],
+  );
+
+  const openAddDialog = useCallback(() => {
+    setEditingIndex(null);
+    setDialogRule(emptyRule());
+    setDialogOpen(true);
+  }, []);
+
+  const openEditDialog = useCallback(
+    (index: number) => {
+      setEditingIndex(index);
+      setDialogRule(rules[index]);
+      setDialogOpen(true);
+    },
+    [rules],
+  );
+
+  const handleDialogSave = useCallback(
+    (rule: FeatureFlagRule) => {
+      let newRules: FeatureFlagRule[];
+      if (editingIndex === null) {
+        newRules = [...rules, rule];
+      } else {
+        newRules = rules.map((r, i) => (i === editingIndex ? rule : r));
+      }
+      setRules(newRules);
+      void saveConfig({ enabled, rules: newRules });
+    },
+    [editingIndex, rules, enabled, saveConfig],
+  );
+
+  const resolveTarget = useCallback(
+    (rule: FeatureFlagRule): string => {
+      switch (rule.scope) {
+        case 'user': {
+          if (!rule.scopeId) return '\u2014';
+          return (
+            memberOptions.find((o) => o.value === rule.scopeId)?.label ??
+            rule.scopeId
+          );
+        }
+        case 'team': {
+          if (!rule.scopeId) return '\u2014';
+          return (
+            teamOptions.find((o) => o.value === rule.scopeId)?.label ??
+            rule.scopeId
+          );
+        }
+        case 'role':
+          return rule.scopeId ?? '\u2014';
+        case 'default':
+          return t('featureFlags.allUsers');
+        default:
+          return '\u2014';
+      }
+    },
+    [memberOptions, teamOptions, t],
+  );
+
+  if (isLoading) {
+    return null;
+  }
+
+  return (
+    <PageSection
+      title={t('featureFlags.title')}
+      description={t('featureFlags.description')}
+      action={
+        <Switch
+          label={t('featureFlags.enabled')}
+          checked={enabled}
+          onCheckedChange={handleToggleEnabled}
+          disabled={cannotManage || upsertMutation.isPending}
+        />
+      }
+    >
+      <Stack gap={6}>
+        <Stack gap={3}>
+          {rules.length > 0 ? (
+            <div className="overflow-x-auto">
+              <table
+                className="w-full text-sm"
+                aria-label={t('featureFlags.title')}
+              >
+                <caption className="sr-only">{t('featureFlags.title')}</caption>
+                <thead>
+                  <tr className="border-border border-b">
+                    <th
+                      scope="col"
+                      className="text-muted-foreground px-3 py-2 text-left font-medium"
+                    >
+                      {t('featureFlags.scope')}
+                    </th>
+                    <th
+                      scope="col"
+                      className="text-muted-foreground px-3 py-2 text-left font-medium"
+                    >
+                      {t('featureFlags.target')}
+                    </th>
+                    <th
+                      scope="col"
+                      className="text-muted-foreground px-3 py-2 text-center font-medium"
+                    >
+                      {t('featureFlags.webSearch')}
+                    </th>
+                    <th
+                      scope="col"
+                      className="text-muted-foreground px-3 py-2 text-center font-medium"
+                    >
+                      {t('featureFlags.codeExecution')}
+                    </th>
+                    <th
+                      scope="col"
+                      className="text-muted-foreground px-3 py-2 text-center font-medium"
+                    >
+                      {t('featureFlags.fileUpload')}
+                    </th>
+                    <th
+                      scope="col"
+                      className="text-muted-foreground px-3 py-2 text-right font-medium"
+                    >
+                      {t('featureFlags.maxContextTokens')}
+                    </th>
+                    <th
+                      scope="col"
+                      className="text-muted-foreground px-3 py-2 text-right font-medium"
+                    >
+                      Actions
+                    </th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {rules.map((rule, index) => (
+                    <tr key={index} className="border-border border-b">
+                      <td className="px-3 py-2 capitalize">{rule.scope}</td>
+                      <td className="px-3 py-2">{resolveTarget(rule)}</td>
+                      <td className="px-3 py-2 text-center">
+                        {rule.webSearch === false ? '\u2718' : '\u2714'}
+                      </td>
+                      <td className="px-3 py-2 text-center">
+                        {rule.codeExecution === false ? '\u2718' : '\u2714'}
+                      </td>
+                      <td className="px-3 py-2 text-center">
+                        {rule.fileUpload === false ? '\u2718' : '\u2714'}
+                      </td>
+                      <td className="px-3 py-2 text-right">
+                        {rule.maxContextTokens != null
+                          ? rule.maxContextTokens.toLocaleString()
+                          : '\u2014'}
+                      </td>
+                      <td className="px-3 py-2 text-right">
+                        <HStack gap={1} justify="end">
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => openEditDialog(index)}
+                            disabled={cannotManage}
+                            aria-label={`${t('featureFlags.editRule')} ${index + 1}`}
+                          >
+                            <Pencil className="size-4" />
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => removeRule(index)}
+                            disabled={cannotManage}
+                            aria-label={`${t('featureFlags.deleteRule')} ${index + 1}`}
+                          >
+                            <Trash2 className="size-4" />
+                          </Button>
+                        </HStack>
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+          ) : (
+            <Text variant="muted" className="text-sm">
+              {t('featureFlags.noRules')}
+            </Text>
+          )}
+
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={openAddDialog}
+            disabled={cannotManage}
+            className="self-start"
+          >
+            <Plus className="mr-1.5 size-4" />
+            {t('featureFlags.addRule')}
+          </Button>
+        </Stack>
+      </Stack>
+
+      <RuleDialog
+        open={dialogOpen}
+        onOpenChange={setDialogOpen}
+        rule={dialogRule}
+        onSave={handleDialogSave}
+        title={
+          editingIndex === null
+            ? t('featureFlags.addRule')
+            : t('featureFlags.editRule')
+        }
+        cannotManage={cannotManage}
+        memberOptions={memberOptions}
+        teamOptions={teamOptions}
+      />
+    </PageSection>
+  );
+}
diff --git a/services/platform/app/routes/dashboard/$id/settings/governance.tsx b/services/platform/app/routes/dashboard/$id/settings/governance.tsx
index a58f3a0a30..7cb91859d4 100644
--- a/services/platform/app/routes/dashboard/$id/settings/governance.tsx
+++ b/services/platform/app/routes/dashboard/$id/settings/governance.tsx
@@ -5,6 +5,7 @@ import { z } from 'zod';
 import { AccessDenied } from '@/app/components/layout/access-denied';
 import { Tabs } from '@/app/components/ui/navigation/tabs';
 import { BudgetEditor } from '@/app/features/settings/governance/components/budget-editor';
+import { FeatureFlagsEditor } from '@/app/features/settings/governance/components/feature-flags-editor';
 import { PiiConfig } from '@/app/features/settings/governance/components/pii-config';
 import { RetentionEditor } from '@/app/features/settings/governance/components/retention-editor';
 import { SystemPromptEditor } from '@/app/features/settings/governance/components/system-prompt-editor';
@@ -29,8 +30,8 @@ function GovernanceSettingsPage() {
   const { id: organizationId } = Route.useParams();
   const search = Route.useSearch();
   const navigate = useNavigate();
-  const { t } = useT('accessDenied');
-  const { t: tGov } = useT('governance');
+  const { t: tAccessDenied } = useT('accessDenied');
+  const { t } = useT('governance');
 
   const ability = useAbility();
   const abilityLoading = useAbilityLoading();
@@ -49,12 +50,12 @@ function GovernanceSettingsPage() {
     () => [
       {
         value: 'system-prompt',
-        label: tGov('tabs.systemPrompt'),
+        label: t('tabs.systemPrompt'),
         content: <SystemPromptEditor organizationId={organizationId} />,
       },
       {
         value: 'budgets',
-        label: tGov('tabs.budgets'),
+        label: t('tabs.budgets'),
         content: <BudgetEditor organizationId={organizationId} />,
       },
       {
@@ -62,18 +63,23 @@ function GovernanceSettingsPage() {
         label: 'Retention',
         content: <RetentionEditor organizationId={organizationId} />,
       },
+      {
+        value: 'feature-controls',
+        label: t('tabs.featureControls'),
+        content: <FeatureFlagsEditor organizationId={organizationId} />,
+      },
       {
         value: 'usage',
-        label: tGov('tabs.usage'),
+        label: t('tabs.usage'),
         content: <UsageDashboard organizationId={organizationId} />,
       },
       {
         value: 'pii',
-        label: tGov('tabs.pii'),
+        label: t('tabs.pii'),
         content: <PiiConfig organizationId={organizationId} />,
       },
     ],
-    [organizationId, tGov],
+    [organizationId, t],
   );
 
   if (abilityLoading) {
@@ -81,7 +87,7 @@ function GovernanceSettingsPage() {
   }
 
   if (ability.cannot('read', 'orgSettings')) {
-    return <AccessDenied message={t('organization')} />;
+    return <AccessDenied message={tAccessDenied('organization')} />;
   }
 
   return (
diff --git a/services/platform/convex/governance/__tests__/feature_enforcement.test.ts b/services/platform/convex/governance/__tests__/feature_enforcement.test.ts
new file mode 100644
index 0000000000..768e77605a
--- /dev/null
+++ b/services/platform/convex/governance/__tests__/feature_enforcement.test.ts
@@ -0,0 +1,320 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+import type {
+  FeatureFlagsConfig,
+  FeatureFlagRule,
+} from '../../../lib/shared/schemas/governance';
+
+vi.mock('../helpers', () => ({
+  readPolicyConfig: vi.fn(),
+}));
+
+const { readPolicyConfig } = await import('../helpers');
+const mockedReadPolicyConfig = vi.mocked(readPolicyConfig);
+
+const { resolveFeatureFlags } = await import('../feature_enforcement');
+
+function createMockCtx() {
+  return {
+    db: {
+      query: vi.fn(),
+    },
+    auth: {
+      getUserIdentity: vi.fn(),
+    },
+  } as never;
+}
+
+describe('resolveFeatureFlags', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('returns all-enabled defaults when no policy exists', async () => {
+    mockedReadPolicyConfig.mockResolvedValue(null);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      'member',
+    );
+
+    expect(result).toEqual({
+      webSearch: true,
+      codeExecution: true,
+      fileUpload: true,
+    });
+  });
+
+  it('returns all-enabled defaults when policy is disabled', async () => {
+    const config: FeatureFlagsConfig = {
+      enabled: false,
+      rules: [{ scope: 'default', webSearch: false, codeExecution: false }],
+    };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      'member',
+    );
+
+    expect(result).toEqual({
+      webSearch: true,
+      codeExecution: true,
+      fileUpload: true,
+    });
+  });
+
+  it('returns all-enabled defaults when rules array is empty', async () => {
+    const config: FeatureFlagsConfig = {
+      enabled: true,
+      rules: [],
+    };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      'member',
+    );
+
+    expect(result).toEqual({
+      webSearch: true,
+      codeExecution: true,
+      fileUpload: true,
+    });
+  });
+
+  it('applies default-scope rule when no more specific rule matches', async () => {
+    const config: FeatureFlagsConfig = {
+      enabled: true,
+      rules: [
+        {
+          scope: 'default',
+          webSearch: false,
+          codeExecution: true,
+          fileUpload: true,
+        },
+      ],
+    };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      'member',
+    );
+
+    expect(result.webSearch).toBe(false);
+    expect(result.codeExecution).toBe(true);
+    expect(result.fileUpload).toBe(true);
+  });
+
+  it('user-scope rule overrides team-scope rule', async () => {
+    const rules: FeatureFlagRule[] = [
+      { scope: 'team', scopeId: 'team_1', webSearch: false },
+      { scope: 'user', scopeId: 'user_1', webSearch: true },
+    ];
+    const config: FeatureFlagsConfig = { enabled: true, rules };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      ['team_1'],
+      'member',
+    );
+
+    expect(result.webSearch).toBe(true);
+  });
+
+  it('team-scope rule overrides role-scope rule', async () => {
+    const rules: FeatureFlagRule[] = [
+      { scope: 'role', scopeId: 'member', webSearch: true },
+      { scope: 'team', scopeId: 'team_1', webSearch: false },
+    ];
+    const config: FeatureFlagsConfig = { enabled: true, rules };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_2',
+      ['team_1'],
+      'member',
+    );
+
+    expect(result.webSearch).toBe(false);
+  });
+
+  it('role-scope rule overrides default rule', async () => {
+    const rules: FeatureFlagRule[] = [
+      { scope: 'default', webSearch: true },
+      { scope: 'role', scopeId: 'member', webSearch: false },
+    ];
+    const config: FeatureFlagsConfig = { enabled: true, rules };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      'member',
+    );
+
+    expect(result.webSearch).toBe(false);
+  });
+
+  it('partial rule preserves defaults for unset fields', async () => {
+    const rules: FeatureFlagRule[] = [
+      { scope: 'user', scopeId: 'user_1', webSearch: false },
+    ];
+    const config: FeatureFlagsConfig = { enabled: true, rules };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      'member',
+    );
+
+    expect(result.webSearch).toBe(false);
+    expect(result.codeExecution).toBe(true);
+    expect(result.fileUpload).toBe(true);
+    expect(result.maxContextTokens).toBeUndefined();
+  });
+
+  it('passes through maxContextTokens from the matching rule', async () => {
+    const rules: FeatureFlagRule[] = [
+      { scope: 'user', scopeId: 'user_1', maxContextTokens: 50000 },
+    ];
+    const config: FeatureFlagsConfig = { enabled: true, rules };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      'member',
+    );
+
+    expect(result.maxContextTokens).toBe(50000);
+  });
+
+  it('returns defaults when no rules match the user', async () => {
+    const rules: FeatureFlagRule[] = [
+      { scope: 'user', scopeId: 'user_other', webSearch: false },
+      { scope: 'team', scopeId: 'team_other', codeExecution: false },
+    ];
+    const config: FeatureFlagsConfig = { enabled: true, rules };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      'member',
+    );
+
+    expect(result).toEqual({
+      webSearch: true,
+      codeExecution: true,
+      fileUpload: true,
+    });
+  });
+
+  it('handles all features disabled for a user', async () => {
+    const rules: FeatureFlagRule[] = [
+      {
+        scope: 'user',
+        scopeId: 'user_1',
+        webSearch: false,
+        codeExecution: false,
+        fileUpload: false,
+        maxContextTokens: 10000,
+      },
+    ];
+    const config: FeatureFlagsConfig = { enabled: true, rules };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      'member',
+    );
+
+    expect(result).toEqual({
+      webSearch: false,
+      codeExecution: false,
+      fileUpload: false,
+      maxContextTokens: 10000,
+    });
+  });
+
+  it('matches team rule when user belongs to multiple teams', async () => {
+    const rules: FeatureFlagRule[] = [
+      { scope: 'team', scopeId: 'team_2', webSearch: false },
+    ];
+    const config: FeatureFlagsConfig = { enabled: true, rules };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      ['team_1', 'team_2', 'team_3'],
+      'member',
+    );
+
+    expect(result.webSearch).toBe(false);
+  });
+
+  it('skips role rule when role is not provided', async () => {
+    const rules: FeatureFlagRule[] = [
+      { scope: 'role', scopeId: 'admin', webSearch: false },
+      { scope: 'default', webSearch: true },
+    ];
+    const config: FeatureFlagsConfig = { enabled: true, rules };
+    mockedReadPolicyConfig.mockResolvedValue(config);
+    const ctx = createMockCtx();
+
+    const result = await resolveFeatureFlags(
+      ctx,
+      'org_1',
+      'user_1',
+      [],
+      undefined,
+    );
+
+    expect(result.webSearch).toBe(true);
+  });
+});
diff --git a/services/platform/convex/governance/mutations.ts b/services/platform/convex/governance/mutations.ts
index ed8621926d..4b23577eba 100644
--- a/services/platform/convex/governance/mutations.ts
+++ b/services/platform/convex/governance/mutations.ts
@@ -2,6 +2,7 @@ import { v } from 'convex/values';
 
 import {
   budgetConfigSchema,
+  featureFlagsConfigSchema,
   piiConfigSchema,
 } from '../../lib/shared/schemas/governance';
 import { mutation } from '../_generated/server';
@@ -51,6 +52,15 @@ export const upsertPolicy = mutation({
       }
     }
 
+    if (args.policyType === 'feature_flags') {
+      const parsed = featureFlagsConfigSchema.safeParse(args.config);
+      if (!parsed.success) {
+        throw new Error(
+          `Invalid feature flags configuration: ${parsed.error.message}`,
+        );
+      }
+    }
+
     const existing = await ctx.db
       .query('governancePolicies')
       .withIndex('by_org_policyType', (q) =>
diff --git a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts
index 878cf69577..e8cfae7780 100644
--- a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts
+++ b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts
@@ -37,6 +37,22 @@ vi.mock('../../debug_log', () => ({
   createDebugLog: () => () => {},
 }));
 
+vi.mock('../../../governance/budget_enforcement', () => ({
+  checkBudget: vi.fn().mockResolvedValue({ allowed: true }),
+}));
+
+vi.mock('../../../governance/feature_enforcement', () => ({
+  resolveFeatureFlags: vi.fn().mockResolvedValue({
+    webSearch: true,
+    codeExecution: true,
+    fileUpload: true,
+  }),
+}));
+
+vi.mock('../../get_user_teams', () => ({
+  getUserTeamIds: vi.fn().mockResolvedValue([]),
+}));
+
 vi.mock('../../message_deduplication', () => ({
   computeDeduplicationState: () => ({
     lastUserMessage: null,
diff --git a/services/platform/convex/lib/agent_chat/internal_actions.ts b/services/platform/convex/lib/agent_chat/internal_actions.ts
index 0c4289da9c..734e557f43 100644
--- a/services/platform/convex/lib/agent_chat/internal_actions.ts
+++ b/services/platform/convex/lib/agent_chat/internal_actions.ts
@@ -141,6 +141,7 @@ export const runAgentGeneration = internalAction({
     maxSteps: v.optional(v.number()),
     deadlineMs: v.optional(v.number()),
     generationParams: v.optional(v.any()),
+    maxContextTokens: v.optional(v.number()),
   },
   handler: async (ctx, args) => {
     const actionStartTime = Date.now();
@@ -171,6 +172,7 @@ export const runAgentGeneration = internalAction({
       maxSteps,
       deadlineMs,
       generationParams,
+      maxContextTokens,
     } = args;
 
     const agentType = narrowStringUnion(
@@ -412,6 +414,7 @@ export const runAgentGeneration = internalAction({
               noCacheToolNames: agentConfig.noCacheToolNames,
               instructions: finalInstructions,
               toolsSummary,
+              maxContextTokens,
             },
             {
               ctx,
diff --git a/services/platform/convex/lib/agent_chat/start_agent_chat.ts b/services/platform/convex/lib/agent_chat/start_agent_chat.ts
index 4d102c337d..cc70506d1e 100644
--- a/services/platform/convex/lib/agent_chat/start_agent_chat.ts
+++ b/services/platform/convex/lib/agent_chat/start_agent_chat.ts
@@ -18,11 +18,13 @@ import { components, internal } from '../../_generated/api';
 import type { Id } from '../../_generated/dataModel';
 import type { MutationCtx } from '../../_generated/server';
 import { checkBudget } from '../../governance/budget_enforcement';
+import { resolveFeatureFlags } from '../../governance/feature_enforcement';
 import { persistentStreaming } from '../../streaming/helpers';
 import type { FileAttachment } from '../attachments';
 import type { AgentType } from '../context_management/constants';
 import { AGENT_CONTEXT_CONFIGS } from '../context_management/constants';
 import { createDebugLog } from '../debug_log';
+import { getUserTeamIds } from '../get_user_teams';
 import {
   computeDeduplicationState,
   type AgentListMessagesResult,
@@ -241,6 +243,61 @@ export async function startAgentChat(
     }
   }
 
+  // Feature flag enforcement — resolve per-user flags and override agent config
+  let enforcedConfig = agentConfig;
+  let governanceMaxContextTokens: number | undefined;
+
+  if (userId) {
+    const userTeamIds = await getUserTeamIds(ctx, userId);
+    const featureFlags = await resolveFeatureFlags(
+      ctx,
+      organizationId,
+      userId,
+      userTeamIds,
+    );
+
+    const needsWebSearchOverride =
+      !featureFlags.webSearch && agentConfig.webSearchMode !== 'off';
+    const needsFileUploadBlock =
+      !featureFlags.fileUpload && attachments && attachments.length > 0;
+
+    if (needsWebSearchOverride || needsFileUploadBlock) {
+      enforcedConfig = { ...agentConfig };
+    }
+
+    if (!featureFlags.webSearch) {
+      enforcedConfig = {
+        ...enforcedConfig,
+        webSearchMode: 'off',
+        convexToolNames: (enforcedConfig.convexToolNames ?? []).filter(
+          (t) => t !== 'web',
+        ),
+      };
+    }
+
+    if (!featureFlags.fileUpload && attachments && attachments.length > 0) {
+      await saveMessage(ctx, components.agent, {
+        threadId,
+        message: {
+          role: 'assistant',
+          content:
+            'File uploads are disabled for your account by organization policy. Please contact your administrator.',
+        },
+      });
+      if (threadMeta) {
+        await ctx.db.patch(threadMeta._id, {
+          generationStatus: 'idle' as const,
+          updatedAt: Date.now(),
+        });
+      }
+      return { messageAlreadyExists, streamId };
+    }
+
+    if (featureFlags.maxContextTokens != null) {
+      governanceMaxContextTokens = featureFlags.maxContextTokens;
+    }
+  }
+
   // Schedule the generic agent action with full configuration
   debugLog('SCHEDULE_ACTION', {
     threadId,
@@ -252,7 +309,7 @@ export async function startAgentChat(
     internal.lib.agent_chat.internal_actions.runAgentGeneration,
     {
       agentType,
-      agentConfig,
+      agentConfig: enforcedConfig,
       model,
       provider,
       debugTag,
@@ -271,6 +328,7 @@ export async function startAgentChat(
       userContext,
       deadlineMs,
       generationParams: args.generationParams,
+      maxContextTokens: governanceMaxContextTokens,
     },
   );
 
diff --git a/services/platform/convex/lib/agent_chat/types.ts b/services/platform/convex/lib/agent_chat/types.ts
index b24fb872c2..c9e90a0c05 100644
--- a/services/platform/convex/lib/agent_chat/types.ts
+++ b/services/platform/convex/lib/agent_chat/types.ts
@@ -139,6 +139,8 @@ export interface RunAgentGenerationArgs {
   maxSteps?: number;
   /** Optional per-request generation parameters from OpenAI compat endpoint */
   generationParams?: GenerationParams;
+  /** Governance-enforced max context tokens (overrides agent config) */
+  maxContextTokens?: number;
 }
 
 /**
diff --git a/services/platform/convex/lib/agent_response/generate_response.ts b/services/platform/convex/lib/agent_response/generate_response.ts
index f8ad11ef6d..494c7e74a0 100644
--- a/services/platform/convex/lib/agent_response/generate_response.ts
+++ b/services/platform/convex/lib/agent_response/generate_response.ts
@@ -182,6 +182,7 @@ export async function generateAgentResponse(
     noCacheToolNames,
     instructions,
     toolsSummary,
+    maxContextTokens,
   } = config;
   const {
     ctx,
@@ -512,7 +513,7 @@ export async function generateAgentResponse(
       threadId,
       additionalContext,
       parentThreadId,
-      maxHistoryTokens: agentConfig.maxHistoryTokens,
+      maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens,
       ragContext: knowledgeContextResult ?? hookData?.ragContext,
       webContext: webContextResult,
     });
@@ -940,7 +941,7 @@ export async function generateAgentResponse(
             threadId,
             additionalContext,
             parentThreadId,
-            maxHistoryTokens: agentConfig.maxHistoryTokens,
+            maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens,
             ragContext: hookData?.ragContext,
           });
 
@@ -1169,7 +1170,7 @@ export async function generateAgentResponse(
             threadId,
             additionalContext,
             parentThreadId,
-            maxHistoryTokens: agentConfig.maxHistoryTokens,
+            maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens,
             ragContext: hookData?.ragContext,
           });
 
diff --git a/services/platform/convex/lib/agent_response/types.ts b/services/platform/convex/lib/agent_response/types.ts
index bb8b900770..dc523c7883 100644
--- a/services/platform/convex/lib/agent_response/types.ts
+++ b/services/platform/convex/lib/agent_response/types.ts
@@ -47,6 +47,8 @@ export interface GenerateResponseConfig {
   responseCacheTtlMs?: number;
   /** Tool names whose invocation should prevent caching the response */
   noCacheToolNames?: string[];
+  /** Governance-enforced max context tokens (overrides agent config maxHistoryTokens) */
+  maxContextTokens?: number;
 }
 
 /**
diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json
index b684b30bbf..430c11f75c 100644
--- a/services/platform/messages/de.json
+++ b/services/platform/messages/de.json
@@ -3585,6 +3585,31 @@
   "governance": {
     "title": "Richtlinien",
     "description": "Konfigurieren Sie organisationsweite Richtlinien und Kontrollen.",
+    "tabs": {
+      "systemPrompt": "System-Prompt",
+      "budgets": "Budgets",
+      "featureControls": "Feature-Steuerung",
+      "usage": "Nutzung"
+    },
+    "featureFlags": {
+      "title": "Feature-Steuerung",
+      "description": "Funktionen pro Benutzer, Team oder Rolle aktivieren oder deaktivieren.",
+      "webSearch": "Websuche",
+      "codeExecution": "Code-Ausführung",
+      "fileUpload": "Datei-Upload",
+      "maxContextTokens": "Max. Kontext-Tokens",
+      "maxContextTokensHint": "Maximale Kontext-Tokens für KI-Antworten. Leer lassen für unbegrenzt.",
+      "enabled": "Feature-Steuerung aktivieren",
+      "scope": "Geltungsbereich",
+      "target": "Ziel",
+      "allUsers": "Alle Benutzer",
+      "addRule": "Regel hinzufügen",
+      "editRule": "Regel bearbeiten",
+      "deleteRule": "Regel löschen",
+      "noRules": "Keine Feature-Regeln konfiguriert. Füge eine Regel hinzu, um Funktionen pro Benutzer, Team oder Rolle zu steuern.",
+      "saved": "Feature-Steuerung gespeichert",
+      "saveFailed": "Feature-Steuerung konnte nicht gespeichert werden"
+    },
     "systemPrompt": {
       "title": "System-Prompt",
       "description": "Legen Sie einen verbindlichen System-Prompt fest, der auf alle Agents angewendet wird und nicht von Benutzern überschrieben werden kann.",
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index c42726c86e..e739bd1b57 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -3638,6 +3638,12 @@
   "governance": {
     "title": "Governance",
     "description": "Configure organization-wide policies and controls.",
+    "tabs": {
+      "systemPrompt": "System prompt",
+      "budgets": "Budgets",
+      "featureControls": "Feature controls",
+      "usage": "Usage"
+    },
     "systemPrompt": {
       "title": "System Prompt",
       "description": "Set a mandatory system prompt that is applied to all agents and cannot be overridden by users.",
@@ -3694,12 +3700,23 @@
       "saved": "Retention policy saved"
     },
     "featureFlags": {
-      "title": "Feature Controls",
+      "title": "Feature controls",
       "description": "Enable or disable features per user, team, or role.",
       "webSearch": "Web search",
       "codeExecution": "Code execution",
+      "fileUpload": "File upload",
       "maxContextTokens": "Max context tokens",
-      "saved": "Feature controls saved"
+      "maxContextTokensHint": "Maximum number of context tokens for AI responses. Leave empty for no limit.",
+      "enabled": "Enable feature controls",
+      "scope": "Scope",
+      "target": "Target",
+      "allUsers": "All users",
+      "addRule": "Add rule",
+      "editRule": "Edit rule",
+      "deleteRule": "Delete rule",
+      "noRules": "No feature control rules configured. Add a rule to manage features per user, team, or role.",
+      "saved": "Feature controls saved",
+      "saveFailed": "Failed to save feature controls"
     },
     "pii": {
       "title": "PII protection",

From e83c322914c402d87f7e248dd17eeb38fe80d451 Mon Sep 17 00:00:00 2001
From: yannickmonney <ym@tale.dev>
Date: Sat, 11 Apr 2026 02:50:06 +0200
Subject: [PATCH 2/5] test(platform): add feature flag enforcement tests for
 startAgentChat

Add tests verifying governance feature flag enforcement:
- maxContextTokens forwarded to scheduled generation args
- web search disabled removes web tool and sets mode off
- file upload disabled blocks request with assistant message
- file upload disabled allows request without attachments
---
 .../__tests__/start_agent_chat.test.ts        | 134 ++++++++++++++++++
 1 file changed, 134 insertions(+)

diff --git a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts
index e8cfae7780..49a661ef15 100644
--- a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts
+++ b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts
@@ -61,6 +61,10 @@ vi.mock('../../message_deduplication', () => ({
   }),
 }));
 
+const { resolveFeatureFlags } =
+  await import('../../../governance/feature_enforcement');
+const mockedResolveFeatureFlags = vi.mocked(resolveFeatureFlags);
+
 const { startAgentChat } = await import('../start_agent_chat');
 
 function createMockCtx(
@@ -161,3 +165,133 @@ describe('startAgentChat — concurrent generation guard', () => {
     });
   });
 });
+
+describe('startAgentChat — feature flag enforcement', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockListMessages.mockResolvedValue({ page: [] });
+    mockSaveMessage.mockResolvedValue({ messageId: 'msg_1' });
+  });
+
+  it('forwards maxContextTokens to scheduled generation when set', async () => {
+    mockedResolveFeatureFlags.mockResolvedValueOnce({
+      webSearch: true,
+      codeExecution: true,
+      fileUpload: true,
+      maxContextTokens: 4096,
+    });
+
+    const ctx = createMockCtx({
+      _id: 'meta_1',
+      generationStatus: 'idle',
+    });
+
+    await startAgentChat(createDefaultArgs(ctx));
+
+    expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(
+      expect.any(Number),
+      'mock-runAgentGeneration',
+      expect.objectContaining({ maxContextTokens: 4096 }),
+    );
+  });
+
+  it('removes web tool and sets webSearchMode off when webSearch is disabled', async () => {
+    mockedResolveFeatureFlags.mockResolvedValueOnce({
+      webSearch: false,
+      codeExecution: true,
+      fileUpload: true,
+    });
+
+    const ctx = createMockCtx({
+      _id: 'meta_1',
+      generationStatus: 'idle',
+    });
+
+    const args = {
+      ...createDefaultArgs(ctx),
+      agentConfig: {
+        name: 'test-agent',
+        instructions: 'test',
+        maxSteps: 5,
+        webSearchMode: 'tool' as const,
+        convexToolNames: ['web', 'rag_search'] as never[],
+      },
+    };
+
+    await startAgentChat(args);
+
+    expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(
+      expect.any(Number),
+      'mock-runAgentGeneration',
+      expect.objectContaining({
+        agentConfig: expect.objectContaining({
+          webSearchMode: 'off',
+          convexToolNames: ['rag_search'],
+        }),
+      }),
+    );
+  });
+
+  it('blocks file upload with assistant message when fileUpload is disabled', async () => {
+    mockedResolveFeatureFlags.mockResolvedValueOnce({
+      webSearch: true,
+      codeExecution: true,
+      fileUpload: false,
+    });
+
+    const ctx = createMockCtx({
+      _id: 'meta_1',
+      generationStatus: 'idle',
+    });
+
+    const args = {
+      ...createDefaultArgs(ctx),
+      attachments: [
+        {
+          fileId: 'file_1' as never,
+          fileName: 'test.pdf',
+          fileType: 'application/pdf',
+          fileSize: 1024,
+        },
+      ],
+    };
+
+    const result = await startAgentChat(args);
+
+    expect(result.streamId).toBe('new-stream-id');
+    expect(mockSaveMessage).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      expect.objectContaining({
+        message: expect.objectContaining({
+          role: 'assistant',
+          content: expect.stringContaining('File uploads are disabled'),
+        }),
+      }),
+    );
+    expect(ctx.db.patch).toHaveBeenCalledWith(
+      'meta_1',
+      expect.objectContaining({
+        generationStatus: 'idle',
+      }),
+    );
+    expect(ctx.scheduler.runAfter).not.toHaveBeenCalled();
+  });
+
+  it('allows request without attachments when fileUpload is disabled', async () => {
+    mockedResolveFeatureFlags.mockResolvedValueOnce({
+      webSearch: true,
+      codeExecution: true,
+      fileUpload: false,
+    });
+
+    const ctx = createMockCtx({
+      _id: 'meta_1',
+      generationStatus: 'idle',
+    });
+
+    await startAgentChat(createDefaultArgs(ctx));
+
+    expect(ctx.scheduler.runAfter).toHaveBeenCalled();
+  });
+});

From 917e25f185e11d70ee063cf63c58307538a7ba70 Mon Sep 17 00:00:00 2001
From: yannickmonney <ym@tale.dev>
Date: Sat, 11 Apr 2026 03:01:42 +0200
Subject: [PATCH 3/5] fix(platform): address CodeRabbit review findings for
 feature flags

Replace all hardcoded English strings in feature-flags-editor with i18n
translation keys (scope/role labels, search placeholders, aria-labels,
submit text, actions header). Use formatNumber() instead of
toLocaleString(). Show loading skeleton with aria-busy instead of
returning null. Normalize maxContextTokens once before reuse in
generate_response. Remove redundant object spread in start_agent_chat.
---
 .../components/feature-flags-editor.test.tsx  |  4 +-
 .../components/feature-flags-editor.tsx       | 81 ++++++++++++-------
 .../convex/lib/agent_chat/start_agent_chat.ts | 13 +--
 .../lib/agent_response/generate_response.ts   | 12 ++-
 services/platform/messages/en.json            | 22 ++++-
 5 files changed, 85 insertions(+), 47 deletions(-)

diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx
index 7b05a94db4..bd343a250d 100644
--- a/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx
+++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx
@@ -112,7 +112,7 @@ describe('FeatureFlagsEditor', () => {
     expect(screen.getByRole('switch')).toBeInTheDocument();
   });
 
-  it('returns null while loading', () => {
+  it('renders loading skeleton while loading', () => {
     mockedUseGovernancePolicy.mockReturnValue({
       data: null,
       isLoading: true,
@@ -120,7 +120,7 @@ describe('FeatureFlagsEditor', () => {
 
     const { container } = render(<FeatureFlagsEditor organizationId="org_1" />);
 
-    expect(container.innerHTML).toBe('');
+    expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument();
   });
 
   describe('accessibility', () => {
diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx
index 8d6d0c015b..9506ee9b0a 100644
--- a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx
+++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx
@@ -4,6 +4,7 @@ import { Pencil, Plus, Trash2 } from 'lucide-react';
 import { useCallback, useEffect, useMemo, useState } from 'react';
 
 import { FormDialog } from '@/app/components/ui/dialog/form-dialog';
+import { Skeleton } from '@/app/components/ui/feedback/skeleton';
 import { Input } from '@/app/components/ui/forms/input';
 import { SearchableSelect } from '@/app/components/ui/forms/searchable-select';
 import { Select } from '@/app/components/ui/forms/select';
@@ -22,6 +23,7 @@ import {
   type FeatureFlagsConfig,
   type FeatureFlagRule,
 } from '@/lib/shared/schemas/governance';
+import { formatNumber } from '@/lib/utils/format/number';
 import { isRecord } from '@/lib/utils/type-guards';
 
 import { useUpsertGovernancePolicy } from '../hooks/mutations';
@@ -31,23 +33,13 @@ interface FeatureFlagsEditorProps {
   organizationId: string;
 }
 
-const SCOPE_OPTIONS = [
-  { value: 'default', label: 'Default' },
-  { value: 'user', label: 'User' },
-  { value: 'team', label: 'Team' },
-  { value: 'role', label: 'Role' },
-];
+const SCOPE_VALUES = ['default', 'user', 'team', 'role'] as const;
 
 function isScopeValue(v: string): v is FeatureFlagRule['scope'] {
-  return SCOPE_OPTIONS.some((o) => o.value === v);
+  return (SCOPE_VALUES as readonly string[]).includes(v);
 }
 
-const ROLE_OPTIONS = [
-  { value: 'admin', label: 'Admin' },
-  { value: 'developer', label: 'Developer' },
-  { value: 'editor', label: 'Editor' },
-  { value: 'member', label: 'Member' },
-];
+const ROLE_VALUES = ['admin', 'developer', 'editor', 'member'] as const;
 
 function emptyRule(): FeatureFlagRule {
   return {
@@ -89,8 +81,27 @@ function RuleDialog({
   teamOptions,
 }: RuleDialogProps) {
   const { t } = useT('governance');
+  const { t: tCommon } = useT('common');
   const [draft, setDraft] = useState(initialRule);
 
+  const scopeOptions = useMemo(
+    () =>
+      SCOPE_VALUES.map((v) => ({
+        value: v,
+        label: t(`featureFlags.scopeLabels.${v}`),
+      })),
+    [t],
+  );
+
+  const roleOptions = useMemo(
+    () =>
+      ROLE_VALUES.map((v) => ({
+        value: v,
+        label: t(`featureFlags.roleLabels.${v}`),
+      })),
+    [t],
+  );
+
   useEffect(() => {
     if (open) {
       setDraft(initialRule);
@@ -122,14 +133,14 @@ function RuleDialog({
       onOpenChange={onOpenChange}
       title={title}
       onSubmit={handleSubmit}
-      submitText="Confirm"
+      submitText={tCommon('actions.confirm')}
     >
       <Stack gap={4}>
         <HStack gap={3} wrap>
           <div className="w-40">
             <Select
               label={t('featureFlags.scope')}
-              options={SCOPE_OPTIONS}
+              options={scopeOptions}
               value={draft.scope}
               onValueChange={(value: string) => {
                 if (isScopeValue(value)) {
@@ -144,8 +155,8 @@ function RuleDialog({
           {draft.scope === 'role' && (
             <div className="w-40">
               <Select
-                label="Role"
-                options={ROLE_OPTIONS}
+                label={t('featureFlags.role')}
+                options={roleOptions}
                 value={draft.scopeId ?? ''}
                 onValueChange={(value) => updateDraft({ scopeId: value })}
                 disabled={cannotManage}
@@ -156,14 +167,16 @@ function RuleDialog({
 
           {draft.scope === 'user' && (
             <div className="w-56">
-              <Text className="mb-1 text-xs font-medium">User</Text>
+              <Text className="mb-1 text-xs font-medium">
+                {t('featureFlags.scopeLabels.user')}
+              </Text>
               <SearchableSelect
                 value={draft.scopeId ?? null}
                 onValueChange={(value) => updateDraft({ scopeId: value })}
                 options={memberOptions}
-                searchPlaceholder="Search users..."
-                emptyText="No users found"
-                aria-label="Select user"
+                searchPlaceholder={t('featureFlags.searchUsers')}
+                emptyText={t('featureFlags.noUsersFound')}
+                aria-label={t('featureFlags.selectUser')}
                 trigger={
                   <button
                     type="button"
@@ -176,7 +189,7 @@ function RuleDialog({
                       {draft.scopeId
                         ? (memberOptions.find((o) => o.value === draft.scopeId)
                             ?.label ?? draft.scopeId)
-                        : 'Select user...'}
+                        : t('featureFlags.selectUser')}
                     </span>
                   </button>
                 }
@@ -186,14 +199,16 @@ function RuleDialog({
 
           {draft.scope === 'team' && (
             <div className="w-56">
-              <Text className="mb-1 text-xs font-medium">Team</Text>
+              <Text className="mb-1 text-xs font-medium">
+                {t('featureFlags.scopeLabels.team')}
+              </Text>
               <SearchableSelect
                 value={draft.scopeId ?? null}
                 onValueChange={(value) => updateDraft({ scopeId: value })}
                 options={teamOptions}
-                searchPlaceholder="Search teams..."
-                emptyText="No teams found"
-                aria-label="Select team"
+                searchPlaceholder={t('featureFlags.searchTeams')}
+                emptyText={t('featureFlags.noTeamsFound')}
+                aria-label={t('featureFlags.selectTeam')}
                 trigger={
                   <button
                     type="button"
@@ -206,7 +221,7 @@ function RuleDialog({
                       {draft.scopeId
                         ? (teamOptions.find((o) => o.value === draft.scopeId)
                             ?.label ?? draft.scopeId)
-                        : 'Select team...'}
+                        : t('featureFlags.selectTeam')}
                     </span>
                   </button>
                 }
@@ -410,7 +425,13 @@ export function FeatureFlagsEditor({
   );
 
   if (isLoading) {
-    return null;
+    return (
+      <div aria-busy="true" className="space-y-3 py-4">
+        <Skeleton className="h-6 w-48" />
+        <Skeleton className="h-4 w-72" />
+        <Skeleton className="h-10 w-full" />
+      </div>
+    );
   }
 
   return (
@@ -477,7 +498,7 @@ export function FeatureFlagsEditor({
                       scope="col"
                       className="text-muted-foreground px-3 py-2 text-right font-medium"
                     >
-                      Actions
+                      {t('featureFlags.actions')}
                     </th>
                   </tr>
                 </thead>
@@ -497,7 +518,7 @@ export function FeatureFlagsEditor({
                       </td>
                       <td className="px-3 py-2 text-right">
                         {rule.maxContextTokens != null
-                          ? rule.maxContextTokens.toLocaleString()
+                          ? formatNumber(rule.maxContextTokens)
                           : '\u2014'}
                       </td>
                       <td className="px-3 py-2 text-right">
diff --git a/services/platform/convex/lib/agent_chat/start_agent_chat.ts b/services/platform/convex/lib/agent_chat/start_agent_chat.ts
index cc70506d1e..fadc98993e 100644
--- a/services/platform/convex/lib/agent_chat/start_agent_chat.ts
+++ b/services/platform/convex/lib/agent_chat/start_agent_chat.ts
@@ -256,20 +256,11 @@ export async function startAgentChat(
       userTeamIds,
     );
 
-    const needsWebSearchOverride =
-      !featureFlags.webSearch && agentConfig.webSearchMode !== 'off';
-    const needsFileUploadBlock =
-      !featureFlags.fileUpload && attachments && attachments.length > 0;
-
-    if (needsWebSearchOverride || needsFileUploadBlock) {
-      enforcedConfig = { ...agentConfig };
-    }
-
     if (!featureFlags.webSearch) {
       enforcedConfig = {
-        ...enforcedConfig,
+        ...agentConfig,
         webSearchMode: 'off',
-        convexToolNames: (enforcedConfig.convexToolNames ?? []).filter(
+        convexToolNames: (agentConfig.convexToolNames ?? []).filter(
           (t) => t !== 'web',
         ),
       };
diff --git a/services/platform/convex/lib/agent_response/generate_response.ts b/services/platform/convex/lib/agent_response/generate_response.ts
index 494c7e74a0..38a33846c9 100644
--- a/services/platform/convex/lib/agent_response/generate_response.ts
+++ b/services/platform/convex/lib/agent_response/generate_response.ts
@@ -508,12 +508,18 @@ export async function generateAgentResponse(
     // Build structured context (history, RAG, web)
     // Note: promptMessage is NOT included - it's passed via `prompt` parameter
     const agentConfig = AGENT_CONTEXT_CONFIGS[agentType];
+    const effectiveMaxHistoryTokens =
+      maxContextTokens != null &&
+      Number.isFinite(maxContextTokens) &&
+      maxContextTokens > 0
+        ? Math.floor(maxContextTokens)
+        : agentConfig.maxHistoryTokens;
     structuredThreadContext = await buildStructuredContext({
       ctx,
       threadId,
       additionalContext,
       parentThreadId,
-      maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens,
+      maxHistoryTokens: effectiveMaxHistoryTokens,
       ragContext: knowledgeContextResult ?? hookData?.ragContext,
       webContext: webContextResult,
     });
@@ -941,7 +947,7 @@ export async function generateAgentResponse(
             threadId,
             additionalContext,
             parentThreadId,
-            maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens,
+            maxHistoryTokens: effectiveMaxHistoryTokens,
             ragContext: hookData?.ragContext,
           });
 
@@ -1170,7 +1176,7 @@ export async function generateAgentResponse(
             threadId,
             additionalContext,
             parentThreadId,
-            maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens,
+            maxHistoryTokens: effectiveMaxHistoryTokens,
             ragContext: hookData?.ragContext,
           });
 
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index e739bd1b57..4a93f6bca7 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -3716,7 +3716,27 @@
       "deleteRule": "Delete rule",
       "noRules": "No feature control rules configured. Add a rule to manage features per user, team, or role.",
       "saved": "Feature controls saved",
-      "saveFailed": "Failed to save feature controls"
+      "saveFailed": "Failed to save feature controls",
+      "actions": "Actions",
+      "role": "Role",
+      "scopeLabels": {
+        "default": "Default",
+        "user": "User",
+        "team": "Team",
+        "role": "Role"
+      },
+      "roleLabels": {
+        "admin": "Admin",
+        "developer": "Developer",
+        "editor": "Editor",
+        "member": "Member"
+      },
+      "searchUsers": "Search users...",
+      "noUsersFound": "No users found",
+      "selectUser": "Select user...",
+      "searchTeams": "Search teams...",
+      "noTeamsFound": "No teams found",
+      "selectTeam": "Select team..."
     },
     "pii": {
       "title": "PII protection",

From d86eaa8f1d19ad39bed073c9d9a7114bf24ac719 Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Sat, 11 Apr 2026 15:02:57 +0800
Subject: [PATCH 4/5] fix(platform): consolidate duplicate governance i18n keys

Remove duplicate governance.tabs and governance.featureFlags entries,
merging feature controls tab label into the canonical tabs section.
---
 services/platform/messages/de.json | 45 +++++++++++-------------------
 services/platform/messages/en.json |  9 ++----
 2 files changed, 18 insertions(+), 36 deletions(-)

diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json
index 430c11f75c..8b3d5d40df 100644
--- a/services/platform/messages/de.json
+++ b/services/platform/messages/de.json
@@ -3585,31 +3585,6 @@
   "governance": {
     "title": "Richtlinien",
     "description": "Konfigurieren Sie organisationsweite Richtlinien und Kontrollen.",
-    "tabs": {
-      "systemPrompt": "System-Prompt",
-      "budgets": "Budgets",
-      "featureControls": "Feature-Steuerung",
-      "usage": "Nutzung"
-    },
-    "featureFlags": {
-      "title": "Feature-Steuerung",
-      "description": "Funktionen pro Benutzer, Team oder Rolle aktivieren oder deaktivieren.",
-      "webSearch": "Websuche",
-      "codeExecution": "Code-Ausführung",
-      "fileUpload": "Datei-Upload",
-      "maxContextTokens": "Max. Kontext-Tokens",
-      "maxContextTokensHint": "Maximale Kontext-Tokens für KI-Antworten. Leer lassen für unbegrenzt.",
-      "enabled": "Feature-Steuerung aktivieren",
-      "scope": "Geltungsbereich",
-      "target": "Ziel",
-      "allUsers": "Alle Benutzer",
-      "addRule": "Regel hinzufügen",
-      "editRule": "Regel bearbeiten",
-      "deleteRule": "Regel löschen",
-      "noRules": "Keine Feature-Regeln konfiguriert. Füge eine Regel hinzu, um Funktionen pro Benutzer, Team oder Rolle zu steuern.",
-      "saved": "Feature-Steuerung gespeichert",
-      "saveFailed": "Feature-Steuerung konnte nicht gespeichert werden"
-    },
     "systemPrompt": {
       "title": "System-Prompt",
       "description": "Legen Sie einen verbindlichen System-Prompt fest, der auf alle Agents angewendet wird und nicht von Benutzern überschrieben werden kann.",
@@ -3665,12 +3640,23 @@
       "saved": "Aufbewahrungsrichtlinie gespeichert"
     },
     "featureFlags": {
-      "title": "Funktionssteuerung",
+      "title": "Feature-Steuerung",
       "description": "Funktionen pro Benutzer, Team oder Rolle aktivieren oder deaktivieren.",
       "webSearch": "Websuche",
       "codeExecution": "Code-Ausführung",
-      "maxContextTokens": "Maximale Kontext-Token",
-      "saved": "Funktionssteuerung gespeichert"
+      "fileUpload": "Datei-Upload",
+      "maxContextTokens": "Max. Kontext-Tokens",
+      "maxContextTokensHint": "Maximale Kontext-Tokens für KI-Antworten. Leer lassen für unbegrenzt.",
+      "enabled": "Feature-Steuerung aktivieren",
+      "scope": "Geltungsbereich",
+      "target": "Ziel",
+      "allUsers": "Alle Benutzer",
+      "addRule": "Regel hinzufügen",
+      "editRule": "Regel bearbeiten",
+      "deleteRule": "Regel löschen",
+      "noRules": "Keine Feature-Regeln konfiguriert. Füge eine Regel hinzu, um Funktionen pro Benutzer, Team oder Rolle zu steuern.",
+      "saved": "Feature-Steuerung gespeichert",
+      "saveFailed": "Feature-Steuerung konnte nicht gespeichert werden"
     },
     "pii": {
       "title": "PII-Schutz",
@@ -3718,7 +3704,8 @@
       "systemPrompt": "System-Prompt",
       "budgets": "Budgets",
       "usage": "Nutzung",
-      "pii": "PII-Schutz"
+      "pii": "PII-Schutz",
+      "featureControls": "Feature-Steuerung"
     }
   }
 }
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index 4a93f6bca7..309a1df70e 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -3638,12 +3638,6 @@
   "governance": {
     "title": "Governance",
     "description": "Configure organization-wide policies and controls.",
-    "tabs": {
-      "systemPrompt": "System prompt",
-      "budgets": "Budgets",
-      "featureControls": "Feature controls",
-      "usage": "Usage"
-    },
     "systemPrompt": {
       "title": "System Prompt",
       "description": "Set a mandatory system prompt that is applied to all agents and cannot be overridden by users.",
@@ -3784,7 +3778,8 @@
       "systemPrompt": "System prompt",
       "budgets": "Budgets",
       "usage": "Usage",
-      "pii": "PII protection"
+      "pii": "PII protection",
+      "featureControls": "Feature controls"
     }
   },
   "prompts": {

From b8a22732bd832c4e952be077ca2c7b10181aeb39 Mon Sep 17 00:00:00 2001
From: larryro <371767072@qq.com>
Date: Sat, 11 Apr 2026 15:10:04 +0800
Subject: [PATCH 5/5] feat(platform): expose resolved feature flags to frontend
 and hide upload when disabled

Add getMyFeatureFlags query that resolves the current user's feature
flags server-side (reactive via Convex subscriptions). Use it in
ChatInterface to hide the file upload button, drop zone, and paste
handler when fileUpload is disabled by governance policy. Also disable
the Confirm button in the rule edit dialog until the user makes a change.
---
 .../features/chat/components/chat-input.tsx   | 30 +++++++++++--------
 .../chat/components/chat-interface.tsx        |  5 ++++
 .../components/feature-flags-editor.tsx       |  5 ++++
 .../settings/governance/hooks/queries.ts      |  6 ++++
 .../platform/convex/governance/queries.ts     | 30 +++++++++++++++++++
 5 files changed, 63 insertions(+), 13 deletions(-)

diff --git a/services/platform/app/features/chat/components/chat-input.tsx b/services/platform/app/features/chat/components/chat-input.tsx
index ea4637e41b..3ec30f0ce8 100644
--- a/services/platform/app/features/chat/components/chat-input.tsx
+++ b/services/platform/app/features/chat/components/chat-input.tsx
@@ -43,6 +43,7 @@ interface ChatInputProps extends Omit<
   uploadFiles: (files: File[]) => Promise<void>;
   removeAttachment: (fileId: Id<'_storage'>) => void;
   clearAttachments: () => FileAttachment[];
+  fileUploadDisabled?: boolean;
   isIndexing?: boolean;
   indexingStatuses?: Map<
     Id<'_storage'>,
@@ -65,6 +66,7 @@ export function ChatInput({
   uploadFiles,
   removeAttachment,
   clearAttachments,
+  fileUploadDisabled = false,
   isIndexing = false,
   indexingStatuses,
   ...restProps
@@ -124,7 +126,7 @@ export function ChatInput({
   };
 
   const handlePaste = (e: React.ClipboardEvent) => {
-    if (inputDisabled) return;
+    if (inputDisabled || fileUploadDisabled) return;
     const items = e.clipboardData?.items;
     if (!items) return;
 
@@ -165,7 +167,7 @@ export function ChatInput({
         className="relative flex h-full min-h-0 flex-1 flex-col"
         onFilesSelected={uploadFiles}
         clickable={false}
-        disabled={inputDisabled}
+        disabled={inputDisabled || fileUploadDisabled}
       >
         <FileUpload.Overlay className="mx-2 rounded-t-3xl" />
         <input
@@ -351,17 +353,19 @@ export function ChatInput({
 
           <HStack justify="between" align="center" className="pb-3">
             <HStack gap={3} align="center">
-              <Tooltip content={tDialogs('attach')} side="top">
-                <Button
-                  variant="ghost"
-                  size="icon"
-                  onClick={() => fileInputRef.current?.click()}
-                  disabled={inputDisabled}
-                  aria-label={tDialogs('attach')}
-                >
-                  <Paperclip className="size-4" />
-                </Button>
-              </Tooltip>
+              {!fileUploadDisabled && (
+                <Tooltip content={tDialogs('attach')} side="top">
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    onClick={() => fileInputRef.current?.click()}
+                    disabled={inputDisabled}
+                    aria-label={tDialogs('attach')}
+                  >
+                    <Paperclip className="size-4" />
+                  </Button>
+                </Tooltip>
+              )}
               <ArenaModeToggle disabled={isLoading} />
               {isArenaMode ? (
                 <ArenaModelSelector organizationId={organizationId} />
diff --git a/services/platform/app/features/chat/components/chat-interface.tsx b/services/platform/app/features/chat/components/chat-interface.tsx
index e99f96e1f5..522c98e84e 100644
--- a/services/platform/app/features/chat/components/chat-interface.tsx
+++ b/services/platform/app/features/chat/components/chat-interface.tsx
@@ -17,6 +17,7 @@ import { api } from '@/convex/_generated/api';
 import { useT } from '@/lib/i18n/client';
 import { cn } from '@/lib/utils/cn';
 
+import { useMyFeatureFlags } from '../../settings/governance/hooks/queries';
 import { useBranchContext } from '../context/branch-context';
 import { useChatLayout } from '../context/chat-layout-context';
 import { useEditAndBranch, useForkOwnThread } from '../hooks/mutations';
@@ -144,6 +145,9 @@ export function ChatInterface({
   const { isIndexing, statusMap: indexingStatuses } =
     useFileIndexingStatus(attachments);
 
+  const { data: featureFlags } = useMyFeatureFlags(organizationId);
+  const fileUploadDisabled = featureFlags?.fileUpload === false;
+
   usePersistedAttachments({
     userId: user?.userId,
     threadId,
@@ -695,6 +699,7 @@ export function ChatInterface({
               uploadFiles={uploadFiles}
               removeAttachment={removeAttachment}
               clearAttachments={clearAttachments}
+              fileUploadDisabled={fileUploadDisabled}
               isIndexing={isIndexing}
               indexingStatuses={indexingStatuses}
             />
diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx
index 9506ee9b0a..2411537659 100644
--- a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx
+++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx
@@ -108,6 +108,10 @@ function RuleDialog({
     }
   }, [open, initialRule]);
 
+  const isDirty = useMemo(() => {
+    return JSON.stringify(draft) !== JSON.stringify(initialRule);
+  }, [draft, initialRule]);
+
   const updateDraft = useCallback((patch: Partial<FeatureFlagRule>) => {
     setDraft((prev) => {
       const updated = { ...prev, ...patch };
@@ -134,6 +138,7 @@ function RuleDialog({
       title={title}
       onSubmit={handleSubmit}
       submitText={tCommon('actions.confirm')}
+      isDirty={isDirty}
     >
       <Stack gap={4}>
         <HStack gap={3} wrap>
diff --git a/services/platform/app/features/settings/governance/hooks/queries.ts b/services/platform/app/features/settings/governance/hooks/queries.ts
index 55ad84f646..187f2ed86c 100644
--- a/services/platform/app/features/settings/governance/hooks/queries.ts
+++ b/services/platform/app/features/settings/governance/hooks/queries.ts
@@ -20,3 +20,9 @@ export function useGovernancePolicy(
     policyType,
   });
 }
+
+export function useMyFeatureFlags(organizationId: string) {
+  return useConvexQuery(api.governance.queries.getMyFeatureFlags, {
+    organizationId,
+  });
+}
diff --git a/services/platform/convex/governance/queries.ts b/services/platform/convex/governance/queries.ts
index 43831ef392..1f0621e365 100644
--- a/services/platform/convex/governance/queries.ts
+++ b/services/platform/convex/governance/queries.ts
@@ -2,8 +2,10 @@ import { v } from 'convex/values';
 
 import { query } from '../_generated/server';
 import { authComponent } from '../auth';
+import { getUserTeamIds } from '../lib/get_user_teams';
 import { getOrganizationMember } from '../lib/rls';
 import { isAdmin } from '../lib/rls/helpers/role_helpers';
+import { resolveFeatureFlags } from './feature_enforcement';
 import { GOVERNANCE_POLICY_TYPES } from './schema';
 
 const policyTypeValidator = v.union(
@@ -152,3 +154,31 @@ export const getUsageSummary = query({
     };
   },
 });
+
+export const getMyFeatureFlags = query({
+  args: {
+    organizationId: v.string(),
+  },
+  handler: async (ctx, args) => {
+    const authUser = await authComponent.getAuthUser(ctx);
+    if (!authUser) {
+      throw new Error('Unauthenticated');
+    }
+
+    const userId = String(authUser._id);
+    const member = await getOrganizationMember(ctx, args.organizationId, {
+      userId,
+      email: authUser.email,
+      name: authUser.name,
+    });
+
+    const teamIds = await getUserTeamIds(ctx, userId);
+    return resolveFeatureFlags(
+      ctx,
+      args.organizationId,
+      userId,
+      teamIds,
+      member.role,
+    );
+  },
+});
