From 721db19a9251171f7c69a2fc25623816e579c087 Mon Sep 17 00:00:00 2001
From: john
Date: Sat, 25 Jul 2026 14:27:07 +0800
Subject: [PATCH] feat(orchestrator): enforce Page Data validation gate
---
agent-run-gateway.mjs | 166 ++++++++++++-----
agent-run-gateway.test.mjs | 175 ++++++++++++++++++
.../memind-orchestrator-boundary.md | 13 +-
ops/src/api/admin.ts | 7 +
ops/src/pages/admin/OrchestratorPage.tsx | 13 ++
scripts/agent-run-worker.mjs | 25 +++
server/portal-gateway-services-bootstrap.mjs | 5 +
...portal-gateway-services-bootstrap.test.mjs | 17 ++
services/orchestrator/admin-config.mjs | 19 ++
services/orchestrator/admin-config.test.mjs | 35 ++++
10 files changed, 432 insertions(+), 43 deletions(-)
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}
+
+
Page Data 准入
+
+ {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 () => {