feat(web): expose EnableIndexDualWrite toggle in admin Global Settings

Closes the operator-facing gap left by M5.d.2: the feature flag was
plumbed server-side (AdminConfiguration model + TableConfigRepository
Store/Map) but the UI had no way to flip it. Without this change the
entire M5.d pipeline was unreachable in production.

Wire-up mirrors the existing allowAgentDowngrade toggle (closest analog
— also a developer/ops-level bool with caveat copy):

- types/adminConfig.ts: add optional enableIndexDualWrite field with a
  doc comment pointing at Plan §M5.d so future maintainers understand
  what toggling it does.
- AdminConfigContext.tsx: add state + setter, hydrate from GET response
  (default false), include in save-spread, include in reset fallback,
  expose via context value + save-useCallback deps.
- AdminConfigSettingsSection.tsx: add prop pair and a checkbox-style
  toggle below the ModernDeployment harmless-IDs input. Copy explains
  the 5 index tables + the 2h safety-net timer + warns that it should
  only be flipped after release-gate verification.
- SectionGlobalSettings.tsx: destructure + forward the new prop pair.

Round-trip is safe: GET (System.Text.Json, camelCase) emits
enableIndexDualWrite, UI spreads it back into PUT body, Newtonsoft
case-insensitive deserialization binds to EnableIndexDualWrite on the
model — so an operator save no longer silently resets the flag.

Verification: npx tsc --noEmit clean, vitest suite 168/168 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-21 13:26:48 +02:00
parent fd7bf0e7ac
commit 65085ff7ce
4 changed files with 50 additions and 1 deletions
@@ -34,6 +34,8 @@ interface AdminConfigContextValue {
setAllowAgentDowngrade: (value: boolean) => void;
modernDeploymentHarmlessEventIds: string;
setModernDeploymentHarmlessEventIds: (value: string) => void;
enableIndexDualWrite: boolean;
setEnableIndexDualWrite: (value: boolean) => void;
// Diagnostics log paths
globalDiagPaths: DiagnosticsLogPath[];
@@ -110,6 +112,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const [opsEventRetentionDays, setOpsEventRetentionDays] = useState(90);
const [allowAgentDowngrade, setAllowAgentDowngrade] = useState(false);
const [modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds] = useState("100, 1005");
const [enableIndexDualWrite, setEnableIndexDualWrite] = useState(false);
// Diagnostics Log Paths state
const [globalDiagPaths, setGlobalDiagPaths] = useState<DiagnosticsLogPath[]>([]);
@@ -162,6 +165,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setOpsEventRetentionDays(data.opsEventRetentionDays ?? 90);
setAllowAgentDowngrade(data.allowAgentDowngrade ?? false);
setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(data.modernDeploymentHarmlessEventIdsJson));
setEnableIndexDualWrite(data.enableIndexDualWrite ?? false);
try {
setGlobalDiagPaths(data.diagnosticsGlobalLogPathsJson ? JSON.parse(data.diagnosticsGlobalLogPathsJson) : []);
} catch {
@@ -255,6 +259,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
opsEventRetentionDays,
allowAgentDowngrade,
modernDeploymentHarmlessEventIdsJson: serializeHarmlessEventIds(modernDeploymentHarmlessEventIds),
enableIndexDualWrite,
};
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, {
@@ -281,7 +286,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingConfig(false);
}
}, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, allowAgentDowngrade, modernDeploymentHarmlessEventIds, getAccessToken]);
}, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
@@ -294,6 +299,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90);
setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false);
setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(adminConfig.modernDeploymentHarmlessEventIdsJson));
setEnableIndexDualWrite(adminConfig.enableIndexDualWrite ?? false);
try {
setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []);
} catch {
@@ -418,6 +424,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
opsEventRetentionDays, setOpsEventRetentionDays,
allowAgentDowngrade, setAllowAgentDowngrade,
modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds,
enableIndexDualWrite, setEnableIndexDualWrite,
globalDiagPaths, setGlobalDiagPaths, savingDiagPaths,
opsAlertRules, setOpsAlertRules,
opsAlertTelegramEnabled, setOpsAlertTelegramEnabled,
@@ -16,6 +16,8 @@ interface AdminConfigSettingsSectionProps {
setAllowAgentDowngrade: (value: boolean) => void;
modernDeploymentHarmlessEventIds: string;
setModernDeploymentHarmlessEventIds: (value: string) => void;
enableIndexDualWrite: boolean;
setEnableIndexDualWrite: (value: boolean) => void;
onSave: () => Promise<void>;
onReset: () => void;
}
@@ -34,6 +36,8 @@ export function AdminConfigSettingsSection({
setAllowAgentDowngrade,
modernDeploymentHarmlessEventIds,
setModernDeploymentHarmlessEventIds,
enableIndexDualWrite,
setEnableIndexDualWrite,
onSave,
onReset,
}: AdminConfigSettingsSectionProps) {
@@ -152,6 +156,34 @@ export function AdminConfigSettingsSection({
</label>
</div>
<div>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
checked={enableIndexDualWrite}
onChange={(e) => setEnableIndexDualWrite(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">V2 Decision Engine: Enable index-table dual-write</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mt-1">
Feature flag for the M5.d secondary-index fan-out. When <strong>off</strong> (default), the V2 telemetry
ingest path writes only the primary <code className="text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">Signals</code> and
<code className="ml-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">DecisionTransitions</code> tables. When
<strong> on</strong>, each committed primary row is also enqueued onto the <code className="text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">telemetry-index-reconcile</code> queue
and fanned out into the 5 cross-session index tables
(<code className="text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">SessionsByTerminal</code>,
<code className="ml-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">SessionsByStage</code>,
<code className="ml-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">DeadEndsByReason</code>,
<code className="ml-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">ClassifierVerdictsByIdLevel</code>,
<code className="ml-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">SignalsByKind</code>).
A 2h timer re-scans the last 4h as a safety net against queue failures. Flip this on only after the
M5.d release-gate tests have been verified in the target environment.
</p>
</span>
</label>
</div>
{adminConfig && (
<div className="bg-blue-50 dark:bg-gray-700 border border-blue-200 dark:border-indigo-600 rounded-lg p-3">
<div className="flex items-start space-x-2">
@@ -12,6 +12,7 @@ export function SectionGlobalSettings() {
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
allowAgentDowngrade, setAllowAgentDowngrade,
modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds,
enableIndexDualWrite, setEnableIndexDualWrite,
handleSaveAdminConfig, handleResetAdminConfig,
} = useAdminConfig();
@@ -32,6 +33,8 @@ export function SectionGlobalSettings() {
setAllowAgentDowngrade={setAllowAgentDowngrade}
modernDeploymentHarmlessEventIds={modernDeploymentHarmlessEventIds}
setModernDeploymentHarmlessEventIds={setModernDeploymentHarmlessEventIds}
enableIndexDualWrite={enableIndexDualWrite}
setEnableIndexDualWrite={setEnableIndexDualWrite}
onSave={handleSaveAdminConfig}
onReset={handleResetAdminConfig}
/>
@@ -24,6 +24,13 @@ export interface AdminConfiguration {
opsAlertSlackWebhookUrl?: string;
allowAgentDowngrade?: boolean;
modernDeploymentHarmlessEventIdsJson?: string;
/**
* Feature flag for the V2 Decision Engine index-table dual-write (Plan §M5.d).
* When true, IngestTelemetryFunction enqueues telemetry-index-reconcile envelopes after
* committing each primary row, and the 2h IndexReconcileTimer re-scans the last 4h as
* a safety net. Default false — keeps pre-M5.d behaviour bit-exact until explicitly flipped.
*/
enableIndexDualWrite?: boolean;
}
export interface OpsAlertRule {