diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index dd5802a..02abf3a 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -539,6 +539,10 @@ export function createAgentRunGateway({ process.env.MEMIND_ORCHESTRATOR_SHADOW_MAX_QUEUE, 100, ), + enforcePageDataWorkflowValidation = envFlag( + process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED, + false, + ), workerIdentity = null, }) { const worker = normalizeAgentRunWorkerIdentity(workerIdentity ?? {}); @@ -721,7 +725,7 @@ export function createAgentRunGateway({ }); } - function dispatchPageDataValidationObservation({ + function buildPageDataValidationObservationInput({ row, runId, checks, @@ -729,8 +733,7 @@ export function createAgentRunGateway({ }) { const userMessage = parseDbJsonColumn(row?.user_message_json, {}) ?? {}; const runOptions = getRunOptionsFromMessage(userMessage); - if (!isPageDataWorkflowRequested(userMessage, runOptions.taskType)) return false; - return validationDispatcher.dispatch({ + return { runId, requestId: row?.request_id ?? null, userId: row?.user_id ?? null, @@ -747,7 +750,74 @@ export function createAgentRunGateway({ source: 'portal-agent-run', observedAt: nowMs(), }, - }); + }; + } + + function dispatchPageDataValidationObservation(input) { + return validationDispatcher.dispatch(input); + } + + async function enforcePageDataValidationGate(input) { + const startedAt = nowMs(); + let result = null; + let gateError = null; + try { + if (typeof observeWorkflowValidation !== 'function') { + gateError = new Error('Page Data Orchestrator validation observer is unavailable'); + gateError.code = 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE'; + throw gateError; + } + result = await observeWorkflowValidation(input); + const verdict = result?.validation?.verdict ?? null; + if (!result?.observed) { + gateError = new Error( + `Page Data Orchestrator validation was not observed: ${result?.reason ?? 'not_selected'}`, + ); + gateError.code = 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE'; + throw gateError; + } + if (verdict !== 'passed') { + gateError = new Error( + `Page Data Orchestrator validation did not pass: ${verdict ?? 'unknown'}`, + ); + gateError.code = verdict === 'failed' + ? 'WORKFLOW_VALIDATION_GATE_REJECTED' + : 'WORKFLOW_VALIDATION_GATE_INCONCLUSIVE'; + throw gateError; + } + await appendEvent(input.runId, 'workflow_validation_gate_passed', { + engine: result.engine ?? 'langgraph', + mode: result.mode ?? 'shadow', + configVersion: result.configVersion ?? null, + kind: result.validation?.kind ?? 'page-data-delivery', + verdict, + latencyMs: Math.max(0, nowMs() - startedAt), + }); + return result; + } catch (error) { + const resolved = gateError ?? error; + const code = String( + gateError?.code ?? 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE', + ).slice(0, 128); + const failure = resolved instanceof Error + ? resolved + : new Error(String(resolved ?? 'Page Data Orchestrator validation failed')); + await appendEvent(input.runId, 'workflow_validation_gate_failed', { + code, + upstreamCode: gateError + ? null + : String(error?.code ?? 'WORKFLOW_VALIDATION_OBSERVER_FAILED').slice(0, 128), + message: String( + failure.message, + ).slice(0, 1000), + verdict: result?.validation?.verdict ?? null, + latencyMs: Math.max(0, nowMs() - startedAt), + }).catch(() => {}); + failure.code = code; + failure.retryable = false; + failure.validationObservationAttempted = true; + throw failure; + } } async function appendRunSnapshot(runId) { @@ -1662,40 +1732,46 @@ export function createAgentRunGateway({ throw error; } } + const validationInput = pageDataIntent + ? buildPageDataValidationObservationInput({ + row, + runId, + checks: [ + { id: 'agent_run_completion', status: 'passed' }, + { + id: 'page_data_binding', + status: typeof syncUserPagesOnSuccess === 'function' ? 'passed' : 'skipped', + }, + { + id: 'page_data_storage_policy', + status: typeof validateRunDeliverables === 'function' ? 'passed' : 'skipped', + }, + { + id: 'page_data_deliverable', + status: requiresPageDeliverable ? 'passed' : 'skipped', + }, + { + id: 'independent_review', + status: runOptions.reviewExecutor ? 'passed' : 'skipped', + }, + ], + metrics: { + pageCount: deliverables?.pageCount ?? 0, + publicationCount: deliverables?.publicationCount ?? 0, + }, + }) + : null; + if (validationInput && enforcePageDataWorkflowValidation) { + await enforcePageDataValidationGate(validationInput); + } const marked = await markRun(runId, 'succeeded', { agent_session_id: sessionId, completed_at: nowMs(), error_message: null, }, { expectedStatus: 'running' }); if (!marked) return false; - if (pageDataIntent) { - dispatchPageDataValidationObservation({ - row, - runId, - checks: [ - { id: 'agent_run_completion', status: 'passed' }, - { - id: 'page_data_binding', - status: typeof syncUserPagesOnSuccess === 'function' ? 'passed' : 'skipped', - }, - { - id: 'page_data_storage_policy', - status: typeof validateRunDeliverables === 'function' ? 'passed' : 'skipped', - }, - { - id: 'page_data_deliverable', - status: requiresPageDeliverable ? 'passed' : 'skipped', - }, - { - id: 'independent_review', - status: runOptions.reviewExecutor ? 'passed' : 'skipped', - }, - ], - metrics: { - pageCount: deliverables?.pageCount ?? 0, - publicationCount: deliverables?.publicationCount ?? 0, - }, - }); + if (validationInput && !enforcePageDataWorkflowValidation) { + dispatchPageDataValidationObservation(validationInput); } if (typeof observePersonalMemoryOnSuccess === 'function') { await observePersonalMemoryOnSuccess({ @@ -1779,16 +1855,22 @@ export function createAgentRunGateway({ } : {}), }, { expectedStatus: 'running' }); - if (!retryable) { - dispatchPageDataValidationObservation({ - row, - runId, - checks: [{ - id: pageDataFailureCheckId(err?.code), - status: 'failed', - codes: [String(err?.code ?? 'AGENT_RUN_FAILED')], - }], - }); + if (!retryable && err?.validationObservationAttempted !== true) { + const userMessage = parseDbJsonColumn(row?.user_message_json, {}) ?? {}; + const runOptions = getRunOptionsFromMessage(userMessage); + if (isPageDataWorkflowRequested(userMessage, runOptions.taskType)) { + dispatchPageDataValidationObservation( + buildPageDataValidationObservationInput({ + row, + runId, + checks: [{ + id: pageDataFailureCheckId(err?.code), + status: 'failed', + codes: [String(err?.code ?? 'AGENT_RUN_FAILED')], + }], + }), + ); + } } if (retryable && autoDispatch) { setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]); diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 0a6a34f..f351c31 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -609,6 +609,181 @@ test('Page Data validation observation failure stays isolated from the Native re assert.equal(failure.code, 'VALIDATION_OBSERVER_UNAVAILABLE'); }); +test('enforced Page Data validation waits for Orchestrator passed before Native success', async () => { + const pool = createFakePool(); + const validationStatuses = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-page-data-gate-pass' }; + }, + async submitSessionReplyForUser() {}, + }, + observeWorkflowRun: async () => ({ observed: true }), + observeWorkflowValidation: async (input) => { + validationStatuses.push(pool.runs.get(input.runId)?.status); + assert.deepEqual( + input.observation.checks + .filter((check) => [ + 'agent_run_completion', + 'page_data_binding', + 'page_data_storage_policy', + ].includes(check.id)) + .map((check) => [check.id, check.status]), + [ + ['agent_run_completion', 'passed'], + ['page_data_binding', 'passed'], + ['page_data_storage_policy', 'passed'], + ], + ); + return { + observed: true, + engine: 'langgraph', + mode: 'shadow', + configVersion: 12, + validation: { + kind: 'page-data-delivery', + verdict: 'passed', + }, + }; + }, + syncUserPagesOnSuccess: async () => ({ + pageDataBind: { errors: [] }, + pageDataRelativePaths: [], + }), + validateRunDeliverables: async () => ({ errors: [] }), + enforcePageDataWorkflowValidation: true, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-page-data-gate-pass', { + requestId: 'req-page-data-gate-pass', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '解释 Page Data API' }], + metadata: { + displayText: '解释 Page Data API', + memindRun: { pgRequired: true }, + }, + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.deepEqual(validationStatuses, ['running']); + assert.ok(pool.events.some( + (event) => event.runId === run.id + && event.eventType === 'workflow_validation_gate_passed', + )); + assert.equal(pool.events.some( + (event) => event.runId === run.id + && event.eventType === 'workflow_validation_observation_completed', + ), false); +}); + +test('enforced Page Data validation fails closed on an inconclusive verdict', async () => { + const pool = createFakePool(); + let validationCalls = 0; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-page-data-gate-inconclusive' }; + }, + async submitSessionReplyForUser() {}, + }, + observeWorkflowRun: async () => ({ observed: true }), + observeWorkflowValidation: async () => { + validationCalls += 1; + return { + observed: true, + engine: 'langgraph', + mode: 'shadow', + validation: { + kind: 'page-data-delivery', + verdict: 'inconclusive', + }, + }; + }, + enforcePageDataWorkflowValidation: true, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-page-data-gate-inconclusive', { + requestId: 'req-page-data-gate-inconclusive', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '解释 Page Data API' }], + metadata: { + displayText: '解释 Page Data API', + memindRun: { pgRequired: true }, + }, + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'failed'); + assert.equal(validationCalls, 1); + assert.match(pool.runs.get(run.id).error_message, /did not pass: inconclusive/); + const failed = pool.events.find( + (event) => event.runId === run.id + && event.eventType === 'workflow_validation_gate_failed', + ); + assert.ok(failed); + assert.equal( + JSON.parse(failed.dataJson).code, + 'WORKFLOW_VALIDATION_GATE_INCONCLUSIVE', + ); +}); + +test('enforced Page Data validation fails closed without retrying when Orchestrator is unavailable', async () => { + const pool = createFakePool(); + let validationCalls = 0; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-page-data-gate-unavailable' }; + }, + async submitSessionReplyForUser() {}, + }, + observeWorkflowRun: async () => ({ observed: true }), + observeWorkflowValidation: async () => { + validationCalls += 1; + throw Object.assign(new Error('orchestrator unavailable'), { + code: 'ORCHESTRATOR_UNAVAILABLE', + }); + }, + enforcePageDataWorkflowValidation: true, + retryDelaysMs: [1, 1, 1], + }); + + const run = await gateway.createRun('user-page-data-gate-unavailable', { + requestId: 'req-page-data-gate-unavailable', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '解释 Page Data API' }], + metadata: { + displayText: '解释 Page Data API', + memindRun: { pgRequired: true }, + }, + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'failed'); + assert.equal(validationCalls, 1); + assert.equal(pool.runs.get(run.id).attempts, 1); + const failed = pool.events.find( + (event) => event.runId === run.id + && event.eventType === 'workflow_validation_gate_failed', + ); + const failure = JSON.parse(failed.dataJson); + assert.equal(failure.code, 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE'); + assert.equal(failure.upstreamCode, 'ORCHESTRATOR_UNAVAILABLE'); +}); + test('agent run starts a session and marks submitted reply as succeeded', async () => { const pool = createFakePool(); const submitted = []; diff --git a/docs/architecture/memind-orchestrator-boundary.md b/docs/architecture/memind-orchestrator-boundary.md index c628e45..93f369b 100644 --- a/docs/architecture/memind-orchestrator-boundary.md +++ b/docs/architecture/memind-orchestrator-boundary.md @@ -61,6 +61,15 @@ configuration from the Agent Run path, schedule background observations, or send data to the Orchestrator. Changing memindadm mode alone therefore cannot put existing Native traffic on the Shadow path. +Page Data validation enforcement has a second independent Portal gate: +`MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED=1`. It is off by +default and becomes effective only while Shadow wiring is effective. When +enabled, a Page Data run remains `running` after the Native delivery guards +finish until the Orchestrator checkpoints a `passed` validation observation. +`failed`, `inconclusive`, not-selected, timeout, and unavailable outcomes fail +closed and prevent the run from being marked successful. The Orchestrator still +does not publish artifacts or receive user content in this mode. + memindadm projects the requested and effective wiring states separately. Canary readiness requires `shadow_wiring_enabled=true`, so a saved Shadow mode with a closed Portal gate is visible and cannot be mistaken for a collecting Shadow @@ -187,7 +196,9 @@ workspace paths, dataset contents, credentials, and database identifiers must not cross this boundary. The Orchestrator derives and checkpoints a `passed`/`failed`/`inconclusive` verdict and emits `workflow_validation_observed`. In the initial observation phase this verdict -does not mutate the Native run result or publication state. +does not mutate the Native run result or publication state. With the independent +Page Data validation gate enabled, Portal waits for `passed` before committing +the Native success transition; the default remains observation-only. The graph keeps three deterministic control-plane nodes: diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index 857eb47..8109abb 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -228,6 +228,13 @@ export type OrchestratorRuntime = { reason: string | null; environmentGate: boolean; }; + pageDataValidationGate: { + requested: boolean; + enabled: boolean; + failClosed: boolean; + reason: string | null; + environmentGate: boolean; + }; executionHandoff: { implemented: boolean; requested: boolean; diff --git a/ops/src/pages/admin/OrchestratorPage.tsx b/ops/src/pages/admin/OrchestratorPage.tsx index 2df94a6..1c8d795 100644 --- a/ops/src/pages/admin/OrchestratorPage.tsx +++ b/ops/src/pages/admin/OrchestratorPage.tsx @@ -182,6 +182,19 @@ export function OrchestratorPage() { {shadowWiringLabel}
++ {state.runtime.pageDataValidationGate.enabled + ? '强制通过' + : state.runtime.pageDataValidationGate.requested + ? '等待 Shadow wiring' + : '仅观察'} +
+{runtimeMessage} diff --git a/scripts/agent-run-worker.mjs b/scripts/agent-run-worker.mjs index 1b7ba0e..c08ace8 100644 --- a/scripts/agent-run-worker.mjs +++ b/scripts/agent-run-worker.mjs @@ -8,6 +8,8 @@ import { createLlmProviderService } from '../llm-providers.mjs'; import { createTkmindProxy } from '../tkmind-proxy.mjs'; import { createToolGateway } from '../tool-gateway.mjs'; import { createUserAuth } from '../user-auth.mjs'; +import { createOrchestratorAdminConfigService } from '../services/orchestrator/admin-config.mjs'; +import { createWorkflowShadowObserver } from '../services/orchestrator/shadow-observer.mjs'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -32,6 +34,12 @@ function parseTargets() { return [process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006']; } +function isEnabledFlag(value) { + return ['1', 'true', 'yes', 'on'].includes( + String(value ?? '').trim().toLowerCase(), + ); +} + function parseArgs(argv) { const args = { once: false, @@ -87,6 +95,15 @@ if (args.help) { } const pool = createDbPool(); +const workflowShadowEnabled = isEnabledFlag( + process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED, +); +const workflowShadowObserver = workflowShadowEnabled + ? createWorkflowShadowObserver({ + configService: createOrchestratorAdminConfigService(pool), + serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN, + }) + : null; const baseUserAuth = createUserAuth(pool, { usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'), h5Root: root, @@ -124,6 +141,14 @@ const gateway = createAgentRunGateway({ autoDispatch: false, maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000), + observeWorkflowRun: workflowShadowObserver, + observeWorkflowValidation: + workflowShadowObserver?.observeValidation ?? null, + enforcePageDataWorkflowValidation: + workflowShadowEnabled + && isEnabledFlag( + process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED, + ), }); let stopping = false; diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs index f6be74b..f975d97 100644 --- a/server/portal-gateway-services-bootstrap.mjs +++ b/server/portal-gateway-services-bootstrap.mjs @@ -331,6 +331,11 @@ export function bootstrapPortalGatewayServices({ observeWorkflowRun: workflowShadowObserver, observeWorkflowValidation: workflowShadowObserver?.observeValidation ?? null, + enforcePageDataWorkflowValidation: + workflowShadowEnabled + && isEnabledFlag( + env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED, + ), observePersonalMemoryOnSuccess: async ({ userId, sessionId, diff --git a/server/portal-gateway-services-bootstrap.test.mjs b/server/portal-gateway-services-bootstrap.test.mjs index dc79f14..11602ff 100644 --- a/server/portal-gateway-services-bootstrap.test.mjs +++ b/server/portal-gateway-services-bootstrap.test.mjs @@ -168,6 +168,7 @@ test('preserves Proxy, Tool, and Agent gateway wiring', () => { assert.equal(captured.agentOptions.runTimeoutMs, 9000); assert.equal(captured.agentOptions.observeWorkflowRun, null); assert.equal(captured.agentOptions.observeWorkflowValidation, null); + assert.equal(captured.agentOptions.enforcePageDataWorkflowValidation, false); assert.equal(captured.agentOptions.maxConcurrentShadowObservations, 2); assert.equal(captured.agentOptions.maxQueuedShadowObservations, 100); }); @@ -184,6 +185,7 @@ test('wires Shadow observation only behind the explicit environment gate', () => MEMIND_ORCHESTRATOR_SERVICE_TOKEN: 'shadow-token', MEMIND_ORCHESTRATOR_SHADOW_MAX_CONCURRENCY: '4', MEMIND_ORCHESTRATOR_SHADOW_MAX_QUEUE: '25', + MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1', }, createOrchestratorAdminConfigServiceFn(receivedPool) { assert.equal(receivedPool, setup.pool); @@ -201,10 +203,25 @@ test('wires Shadow observation only behind the explicit environment gate', () => assert.equal(receivedObserverOptions.serviceToken, 'shadow-token'); assert.equal(agentOptions.observeWorkflowRun, observer); assert.equal(agentOptions.observeWorkflowValidation, validationObserver); + assert.equal(agentOptions.enforcePageDataWorkflowValidation, true); assert.equal(agentOptions.maxConcurrentShadowObservations, 4); assert.equal(agentOptions.maxQueuedShadowObservations, 25); }); +test('keeps the Page Data validation gate disabled without Shadow wiring', () => { + const setup = createSetup({ + env: { + MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1', + }, + }); + + bootstrapPortalGatewayServices(setup.options); + + const { agentOptions } = setup.getCaptured(); + assert.equal(agentOptions.observeWorkflowValidation, null); + assert.equal(agentOptions.enforcePageDataWorkflowValidation, false); +}); + test('preserves local asset reads and optional asset absence', async () => { const setup = createSetup(); bootstrapPortalGatewayServices(setup.options); diff --git a/services/orchestrator/admin-config.mjs b/services/orchestrator/admin-config.mjs index fa764b1..f65a9ba 100644 --- a/services/orchestrator/admin-config.mjs +++ b/services/orchestrator/admin-config.mjs @@ -151,6 +151,10 @@ function runtimeState(config, env = process.env) { env.MEMIND_ORCHESTRATOR_EXECUTION_HANDOFF_ENABLED, false, ); + const environmentPageDataValidationGate = normalizeBoolean( + env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED, + false, + ); const configured = config.primaryEngine !== WORKFLOW_ENGINE.LANGGRAPH || Boolean(config.serviceUrl); let reason = null; if (killSwitch) reason = 'kill_switch'; @@ -175,6 +179,8 @@ function runtimeState(config, env = process.env) { const shadowObservationEnabled = !reason && environmentShadowObservationGate && shadowObservationRequested; + const pageDataValidationGateEnabled = environmentPageDataValidationGate + && shadowObservationEnabled; return { killSwitch, configured, @@ -203,6 +209,19 @@ function runtimeState(config, env = process.env) { : 'environment_shadow_observation_gate_disabled', environmentGate: environmentShadowObservationGate, }, + pageDataValidationGate: { + requested: environmentPageDataValidationGate, + enabled: pageDataValidationGateEnabled, + failClosed: true, + reason: pageDataValidationGateEnabled + ? null + : !environmentPageDataValidationGate + ? 'environment_page_data_validation_gate_disabled' + : !shadowObservationEnabled + ? 'shadow_observation_not_enabled' + : 'page_data_validation_gate_disabled', + environmentGate: environmentPageDataValidationGate, + }, executionHandoff: { implemented: EXECUTION_HANDOFF_IMPLEMENTED, requested: executionHandoffRequested, diff --git a/services/orchestrator/admin-config.test.mjs b/services/orchestrator/admin-config.test.mjs index 5081120..c254547 100644 --- a/services/orchestrator/admin-config.test.mjs +++ b/services/orchestrator/admin-config.test.mjs @@ -46,6 +46,13 @@ test('orchestrator config defaults to a disabled native-safe runtime', async () reason: 'shadow_observation_not_requested', environmentGate: false, }); + assert.deepEqual(state.runtime.pageDataValidationGate, { + requested: false, + enabled: false, + failClosed: true, + reason: 'environment_page_data_validation_gate_disabled', + environmentGate: false, + }); assert.equal(state.engines.find((engine) => engine.id === 'native').configured, true); assert.equal(state.engines.find((engine) => engine.id === 'langgraph').configured, false); assert.deepEqual( @@ -275,6 +282,34 @@ test('Shadow runtime reports the independent Portal observation wiring gate', as reason: null, environmentGate: true, }); + assert.deepEqual(runtime.runtime.pageDataValidationGate, { + requested: false, + enabled: false, + failClosed: true, + reason: 'environment_page_data_validation_gate_disabled', + environmentGate: false, + }); +}); + +test('Page Data validation gate is effective only with Shadow wiring and its own environment gate', async () => { + const service = createOrchestratorAdminConfigService(createPool(), { + env: { + MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED: '1', + MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1', + }, + }); + await service.updateAdminConfig({ + mode: 'shadow', + serviceUrl: 'http://127.0.0.1:8093', + }); + const runtime = await service.getRuntimeState(); + assert.deepEqual(runtime.runtime.pageDataValidationGate, { + requested: true, + enabled: true, + failClosed: true, + reason: null, + environmentGate: true, + }); }); test('orchestrator emergency kill switch always forces native selection', async () => {