mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): gate primary on shadow capture evidence
This commit is contained in:
+22
-1
@@ -26,6 +26,9 @@ class Application {
|
||||
private manualPrimaryRuntime?: {
|
||||
stop(): Promise<'drained' | 'timed_out'>;
|
||||
};
|
||||
private legacyShadowCaptureEvidence?: {
|
||||
close(): Promise<unknown>;
|
||||
};
|
||||
private isShuttingDown = false;
|
||||
private workerMetadataMap = new Map<number, WorkerMetadata>();
|
||||
private httpWorker?: Worker;
|
||||
@@ -248,7 +251,16 @@ class Application {
|
||||
const { bootstrapLegacyShadowStartupReconciliation } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapLegacyShadowStartupReconciliation'
|
||||
);
|
||||
await bootstrapLegacyShadowStartupReconciliation();
|
||||
const legacyShadowStartup =
|
||||
await bootstrapLegacyShadowStartupReconciliation();
|
||||
|
||||
const { bootstrapLegacyShadowCaptureEvidence } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapLegacyShadowCaptureEvidence'
|
||||
);
|
||||
this.legacyShadowCaptureEvidence =
|
||||
await bootstrapLegacyShadowCaptureEvidence({
|
||||
startup: legacyShadowStartup,
|
||||
});
|
||||
|
||||
const { bootstrapDefaultManualPrimaryRuntime } = await import(
|
||||
'./runtime/adapters/legacy/bootstrapDefaultManualPrimaryRuntime'
|
||||
@@ -260,6 +272,7 @@ class Application {
|
||||
server = await this.httpServerService.initialize(this.app, config.port);
|
||||
} catch (error) {
|
||||
await this.stopManualPrimaryRuntime();
|
||||
await this.closeLegacyShadowCaptureEvidence();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -307,6 +320,7 @@ class Application {
|
||||
if (serviceType === 'http') {
|
||||
await this.stopManualPrimaryRuntime();
|
||||
await this.httpServerService?.shutdown();
|
||||
await this.closeLegacyShadowCaptureEvidence();
|
||||
} else {
|
||||
await this.grpcServerService?.shutdown();
|
||||
}
|
||||
@@ -330,6 +344,13 @@ class Application {
|
||||
Logger.error('[runtime-activation] shutdown failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async closeLegacyShadowCaptureEvidence(): Promise<void> {
|
||||
const evidence = this.legacyShadowCaptureEvidence;
|
||||
this.legacyShadowCaptureEvidence = undefined;
|
||||
if (!evidence) return;
|
||||
await evidence.close();
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Application();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { constants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
@@ -9,6 +10,10 @@ import type {
|
||||
RuntimeRolloutLoadAudit,
|
||||
RuntimeRolloutLoadResult,
|
||||
} from '../../ports/runtimeRolloutLoader';
|
||||
import {
|
||||
parseLegacyShadowPrimaryGateReceipt,
|
||||
type LegacyShadowPrimaryGateReceipt,
|
||||
} from '../../domain/legacyShadowPrimaryGate';
|
||||
|
||||
export type {
|
||||
RuntimeRolloutLoadAudit,
|
||||
@@ -17,6 +22,7 @@ export type {
|
||||
} from '../../ports/runtimeRolloutLoader';
|
||||
|
||||
export const MAX_RUNTIME_ROLLOUT_MANIFEST_BYTES = 64 * 1024;
|
||||
export const MAX_RUNTIME_PRIMARY_GATE_RECEIPT_BYTES = 64 * 1024;
|
||||
|
||||
export interface RuntimeRolloutManifestLoaderOptions {
|
||||
clock?: { now(): number };
|
||||
@@ -34,6 +40,47 @@ function rejected(
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPrimaryGateReceipt(
|
||||
manifestPath: string,
|
||||
receiptFile: string,
|
||||
expectedSha256: string,
|
||||
approvedAtMs: number,
|
||||
): Promise<LegacyShadowPrimaryGateReceipt> {
|
||||
const receiptPath = path.join(path.dirname(manifestPath), receiptFile);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
receiptPath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
(stat.mode & 0o077) !== 0 ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_RUNTIME_PRIMARY_GATE_RECEIPT_BYTES
|
||||
) {
|
||||
throw new TypeError('Primary gate receipt file shape is invalid');
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
if (createHash('sha256').update(bytes).digest('hex') !== expectedSha256) {
|
||||
throw new TypeError('Primary gate receipt digest does not match');
|
||||
}
|
||||
const receipt = parseLegacyShadowPrimaryGateReceipt(
|
||||
JSON.parse(bytes.toString('utf8')),
|
||||
);
|
||||
if (
|
||||
receipt.assessment !== 'eligible' ||
|
||||
receipt.generatedAtMs > approvedAtMs
|
||||
) {
|
||||
throw new TypeError('Primary gate receipt is not eligible for approval');
|
||||
}
|
||||
return receipt;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRuntimeRolloutManifest(
|
||||
sourcePath: string,
|
||||
options: RuntimeRolloutManifestLoaderOptions = {},
|
||||
@@ -90,10 +137,29 @@ export async function loadRuntimeRolloutManifest(
|
||||
try {
|
||||
const decision = parseRuntimeRolloutManifest(value, evaluatedAtMs);
|
||||
const status = decision.manifest.enabled ? 'accepted' : 'disabled';
|
||||
let primaryGateReceipt: LegacyShadowPrimaryGateReceipt | undefined;
|
||||
if (decision.manifest.enabled) {
|
||||
try {
|
||||
primaryGateReceipt = await loadPrimaryGateReceipt(
|
||||
sourcePath,
|
||||
decision.manifest.primaryGate.receiptFile,
|
||||
decision.manifest.primaryGate.receiptSha256,
|
||||
decision.manifest.approvedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
return rejected(
|
||||
hashedAudit,
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
? 'PRIMARY_GATE_READ_FAILED'
|
||||
: 'PRIMARY_GATE_INVALID',
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status,
|
||||
policy: decision.policy,
|
||||
manifest: decision.manifest,
|
||||
...(primaryGateReceipt === undefined ? {} : { primaryGateReceipt }),
|
||||
audit: {
|
||||
...hashedAudit,
|
||||
status,
|
||||
|
||||
@@ -52,8 +52,25 @@ export async function bootstrapDefaultManualPrimaryRuntime(
|
||||
Logger.info(`[runtime-activation] ${JSON.stringify(record)}`);
|
||||
});
|
||||
let stackModule: DefaultManualPrimaryStackModule | undefined;
|
||||
let selectedProfile: 'edge' | 'standalone' | undefined;
|
||||
if (selected) {
|
||||
try {
|
||||
const profile =
|
||||
options.deploymentProfile ??
|
||||
parseDeploymentProfile(process.env.QL_DEPLOYMENT_PROFILE);
|
||||
if (profile === 'cluster-control' || profile === 'worker') {
|
||||
throw new Error('Local Primary requires edge or standalone Profile');
|
||||
}
|
||||
if (
|
||||
load.primaryGateReceipt?.assessment !== 'eligible' ||
|
||||
load.primaryGateReceipt.origin !== 'manual' ||
|
||||
load.primaryGateReceipt.profile !== profile
|
||||
) {
|
||||
throw new Error(
|
||||
'Primary gate receipt does not authorize this deployment Profile',
|
||||
);
|
||||
}
|
||||
selectedProfile = profile;
|
||||
stackModule = await (
|
||||
options.loadStack ?? (() => import('./defaultManualPrimaryActivation'))
|
||||
)();
|
||||
@@ -87,9 +104,7 @@ export async function bootstrapDefaultManualPrimaryRuntime(
|
||||
}
|
||||
return stackModule.createDefaultManualPrimaryActivationStack(rollout, {
|
||||
...activationOptions,
|
||||
deploymentProfile:
|
||||
options.deploymentProfile ??
|
||||
parseDeploymentProfile(process.env.QL_DEPLOYMENT_PROFILE),
|
||||
deploymentProfile: selectedProfile!,
|
||||
});
|
||||
},
|
||||
install: options.install ?? installManualPrimaryExecutionRouter,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import config from '../../../config';
|
||||
import Logger from '../../../loaders/logger';
|
||||
import {
|
||||
createLegacyShadowCaptureReport,
|
||||
type LegacyShadowCaptureReport,
|
||||
type LegacyShadowCaptureSnapshot,
|
||||
} from '../../application/legacyShadowCaptureAuthority';
|
||||
import {
|
||||
configuredLegacyShadowOrigins,
|
||||
legacyShadowCaptureSnapshot,
|
||||
} from '../../compatibility/legacyExecutionBridge';
|
||||
import {
|
||||
parseDeploymentProfile,
|
||||
type DeploymentProfile,
|
||||
} from '../../domain/deploymentProfile';
|
||||
import type { ExecutionOrigin } from '../../domain/run';
|
||||
import type {
|
||||
LegacyShadowStartupAudit,
|
||||
LegacyShadowStartupDifferenceReport,
|
||||
} from './bootstrapLegacyShadowStartupReconciliation';
|
||||
|
||||
export const LEGACY_SHADOW_CAPTURE_EVIDENCE_SCHEMA =
|
||||
'qinglong/legacy-shadow-capture-evidence@v1';
|
||||
export const LEGACY_SHADOW_CAPTURE_EVIDENCE_FILE_ENV =
|
||||
'QL3_SHADOW_CAPTURE_EVIDENCE_FILE';
|
||||
|
||||
export interface LegacyShadowCaptureEvidence {
|
||||
schema: typeof LEGACY_SHADOW_CAPTURE_EVIDENCE_SCHEMA;
|
||||
profile: 'edge' | 'standalone';
|
||||
startup: LegacyShadowStartupDifferenceReport;
|
||||
capture: LegacyShadowCaptureReport;
|
||||
qualification: {
|
||||
passed: boolean;
|
||||
startupConverged: boolean;
|
||||
originCoverageExact: boolean;
|
||||
captureComplete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type LegacyShadowCaptureEvidenceAudit =
|
||||
| { state: 'disabled' }
|
||||
| { state: 'profile_rejected'; profile: 'cluster-control' | 'worker' }
|
||||
| { state: 'armed'; profile: 'edge' | 'standalone'; origins: number }
|
||||
| {
|
||||
state: 'exported';
|
||||
profile: 'edge' | 'standalone';
|
||||
origins: number;
|
||||
qualified: boolean;
|
||||
}
|
||||
| { state: 'failed'; errorType: string };
|
||||
|
||||
export interface LegacyShadowCaptureEvidenceHandle {
|
||||
active: boolean;
|
||||
close(): Promise<LegacyShadowCaptureEvidenceAudit>;
|
||||
}
|
||||
|
||||
export interface BootstrapLegacyShadowCaptureEvidenceOptions {
|
||||
startup: LegacyShadowStartupAudit;
|
||||
origins?: readonly ExecutionOrigin[];
|
||||
profile?: DeploymentProfile;
|
||||
outputPath?: string;
|
||||
snapshot?: (
|
||||
origins: readonly ExecutionOrigin[],
|
||||
) => LegacyShadowCaptureSnapshot;
|
||||
write?: (
|
||||
outputPath: string,
|
||||
evidence: LegacyShadowCaptureEvidence,
|
||||
) => Promise<void>;
|
||||
audit?: (record: LegacyShadowCaptureEvidenceAudit) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const FILE_NAME_PATTERN =
|
||||
/^(?=.{1,128}$)(?!\.)(?!.*\.\.)(?:[A-Za-z0-9][A-Za-z0-9._-]*)\.json$/u;
|
||||
|
||||
function configuredOutputPath(): string | undefined {
|
||||
const fileName = process.env[LEGACY_SHADOW_CAPTURE_EVIDENCE_FILE_ENV]?.trim();
|
||||
if (!fileName) return undefined;
|
||||
if (
|
||||
!FILE_NAME_PATTERN.test(fileName) ||
|
||||
path.basename(fileName) !== fileName
|
||||
) {
|
||||
throw new TypeError('Legacy Shadow capture evidence filename is invalid');
|
||||
}
|
||||
return path.join(config.configPath, fileName);
|
||||
}
|
||||
|
||||
async function writeEvidence(
|
||||
outputPath: string,
|
||||
evidence: LegacyShadowCaptureEvidence,
|
||||
): Promise<void> {
|
||||
if (!path.isAbsolute(outputPath)) {
|
||||
throw new TypeError('Legacy Shadow capture evidence path must be absolute');
|
||||
}
|
||||
const handle = await fs.open(outputPath, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(evidence)}\n`, 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function exactOriginCoverage(
|
||||
origins: readonly ExecutionOrigin[],
|
||||
startup: LegacyShadowStartupDifferenceReport,
|
||||
): boolean {
|
||||
return (
|
||||
startup.configuredOriginCount === origins.length &&
|
||||
startup.byOrigin.length === origins.length &&
|
||||
origins.every((origin, index) => startup.byOrigin[index]?.origin === origin)
|
||||
);
|
||||
}
|
||||
|
||||
async function emitAudit(
|
||||
audit: (record: LegacyShadowCaptureEvidenceAudit) => void | Promise<void>,
|
||||
record: LegacyShadowCaptureEvidenceAudit,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await audit(record);
|
||||
} catch {
|
||||
// Evidence diagnostics must not change Legacy startup or shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arms one process-lifetime capture window after startup reconciliation. It has
|
||||
* no timer or watcher and writes exactly one owner-private report on a clean
|
||||
* shutdown when an explicit filename is configured.
|
||||
*/
|
||||
export async function bootstrapLegacyShadowCaptureEvidence(
|
||||
options: BootstrapLegacyShadowCaptureEvidenceOptions,
|
||||
): Promise<LegacyShadowCaptureEvidenceHandle> {
|
||||
const audit =
|
||||
options.audit ??
|
||||
((record: LegacyShadowCaptureEvidenceAudit) => {
|
||||
Logger.info(`[ql3-shadow-capture] ${JSON.stringify(record)}`);
|
||||
});
|
||||
const origins = [
|
||||
...new Set(options.origins ?? configuredLegacyShadowOrigins()),
|
||||
];
|
||||
let outputPath: string | undefined;
|
||||
try {
|
||||
outputPath = options.outputPath ?? configuredOutputPath();
|
||||
} catch (error) {
|
||||
const record: LegacyShadowCaptureEvidenceAudit = {
|
||||
state: 'failed',
|
||||
errorType: error instanceof Error ? error.name : 'unknown',
|
||||
};
|
||||
await emitAudit(audit, record);
|
||||
return {
|
||||
active: false,
|
||||
async close() {
|
||||
return record;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (origins.length === 0 || outputPath === undefined) {
|
||||
const record: LegacyShadowCaptureEvidenceAudit = { state: 'disabled' };
|
||||
await emitAudit(audit, record);
|
||||
return {
|
||||
active: false,
|
||||
async close() {
|
||||
return record;
|
||||
},
|
||||
};
|
||||
}
|
||||
const profile =
|
||||
options.profile ??
|
||||
parseDeploymentProfile(process.env.QL_DEPLOYMENT_PROFILE);
|
||||
if (profile === 'cluster-control' || profile === 'worker') {
|
||||
const record: LegacyShadowCaptureEvidenceAudit = {
|
||||
state: 'profile_rejected',
|
||||
profile,
|
||||
};
|
||||
await emitAudit(audit, record);
|
||||
return {
|
||||
active: false,
|
||||
async close() {
|
||||
return record;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
options.startup.state !== 'reconciled' ||
|
||||
options.startup.report.profile !== profile
|
||||
) {
|
||||
const record: LegacyShadowCaptureEvidenceAudit = {
|
||||
state: 'failed',
|
||||
errorType: 'LegacyShadowStartupEvidenceUnavailable',
|
||||
};
|
||||
await emitAudit(audit, record);
|
||||
return {
|
||||
active: false,
|
||||
async close() {
|
||||
return record;
|
||||
},
|
||||
};
|
||||
}
|
||||
const startupReport = options.startup.report;
|
||||
const snapshot = options.snapshot ?? legacyShadowCaptureSnapshot;
|
||||
const before = snapshot(origins);
|
||||
const armed: LegacyShadowCaptureEvidenceAudit = {
|
||||
state: 'armed',
|
||||
profile,
|
||||
origins: origins.length,
|
||||
};
|
||||
await emitAudit(audit, armed);
|
||||
let closed: LegacyShadowCaptureEvidenceAudit | undefined;
|
||||
return {
|
||||
active: true,
|
||||
async close() {
|
||||
if (closed) return closed;
|
||||
try {
|
||||
const capture = createLegacyShadowCaptureReport(
|
||||
profile,
|
||||
origins,
|
||||
before,
|
||||
snapshot(origins),
|
||||
);
|
||||
const startupConverged = startupReport.assessment === 'converged';
|
||||
const originCoverageExact = exactOriginCoverage(origins, startupReport);
|
||||
const captureComplete = capture.assessment === 'captured';
|
||||
const evidence: LegacyShadowCaptureEvidence = {
|
||||
schema: LEGACY_SHADOW_CAPTURE_EVIDENCE_SCHEMA,
|
||||
profile,
|
||||
startup: startupReport,
|
||||
capture,
|
||||
qualification: {
|
||||
passed: startupConverged && originCoverageExact && captureComplete,
|
||||
startupConverged,
|
||||
originCoverageExact,
|
||||
captureComplete,
|
||||
},
|
||||
};
|
||||
await (options.write ?? writeEvidence)(outputPath!, evidence);
|
||||
closed = {
|
||||
state: 'exported',
|
||||
profile,
|
||||
origins: origins.length,
|
||||
qualified: evidence.qualification.passed,
|
||||
};
|
||||
} catch (error) {
|
||||
closed = {
|
||||
state: 'failed',
|
||||
errorType: error instanceof Error ? error.name : 'unknown',
|
||||
};
|
||||
}
|
||||
await emitAudit(audit, closed);
|
||||
return closed;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { ExecutionOrigin } from '../domain/run';
|
||||
|
||||
export const LEGACY_SHADOW_CAPTURE_SNAPSHOT_SCHEMA =
|
||||
'qinglong/legacy-shadow-capture-snapshot@v1';
|
||||
export const LEGACY_SHADOW_CAPTURE_REPORT_SCHEMA =
|
||||
'qinglong/legacy-shadow-capture-report@v1';
|
||||
|
||||
export type LegacyShadowCaptureFailureStage =
|
||||
| 'fact'
|
||||
| 'observer'
|
||||
| 'initialization'
|
||||
| 'accept';
|
||||
|
||||
export interface LegacyShadowCaptureCounts {
|
||||
admitted: number;
|
||||
captured: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
failures: Record<LegacyShadowCaptureFailureStage, number>;
|
||||
}
|
||||
|
||||
export interface LegacyShadowCaptureOriginSnapshot
|
||||
extends LegacyShadowCaptureCounts {
|
||||
origin: ExecutionOrigin;
|
||||
}
|
||||
|
||||
export interface LegacyShadowCaptureSnapshot {
|
||||
schema: typeof LEGACY_SHADOW_CAPTURE_SNAPSHOT_SCHEMA;
|
||||
epoch: string;
|
||||
observedAtMs: number;
|
||||
byOrigin: readonly LegacyShadowCaptureOriginSnapshot[];
|
||||
}
|
||||
|
||||
export interface LegacyShadowCaptureReport {
|
||||
schema: typeof LEGACY_SHADOW_CAPTURE_REPORT_SCHEMA;
|
||||
profile: 'edge' | 'standalone';
|
||||
assessment: 'captured' | 'empty' | 'incomplete' | 'failures_observed';
|
||||
epoch: string;
|
||||
window: {
|
||||
basis: 'process_local_legacy_admission';
|
||||
startInclusiveMs: number;
|
||||
endExclusiveMs: number;
|
||||
};
|
||||
configuredOriginCount: number;
|
||||
totals: LegacyShadowCaptureCounts;
|
||||
byOrigin: readonly LegacyShadowCaptureOriginSnapshot[];
|
||||
capturePermille?: number;
|
||||
}
|
||||
|
||||
export interface LegacyShadowCaptureAdmission {
|
||||
captured(): void;
|
||||
failed(stage: LegacyShadowCaptureFailureStage): void;
|
||||
}
|
||||
|
||||
const FAILURE_STAGES: readonly LegacyShadowCaptureFailureStage[] = [
|
||||
'fact',
|
||||
'observer',
|
||||
'initialization',
|
||||
'accept',
|
||||
];
|
||||
|
||||
function emptyCounts(): LegacyShadowCaptureCounts {
|
||||
return {
|
||||
admitted: 0,
|
||||
captured: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
failures: { fact: 0, observer: 0, initialization: 0, accept: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneCounts(
|
||||
source: LegacyShadowCaptureCounts,
|
||||
): LegacyShadowCaptureCounts {
|
||||
return {
|
||||
admitted: source.admitted,
|
||||
captured: source.captured,
|
||||
failed: source.failed,
|
||||
pending: source.pending,
|
||||
failures: { ...source.failures },
|
||||
};
|
||||
}
|
||||
|
||||
function assertCount(value: number, label: string): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new RangeError(`${label} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertConserved(
|
||||
counts: LegacyShadowCaptureCounts,
|
||||
label: string,
|
||||
): void {
|
||||
for (const key of ['admitted', 'captured', 'failed', 'pending'] as const) {
|
||||
assertCount(counts[key], `${label}.${key}`);
|
||||
}
|
||||
for (const stage of FAILURE_STAGES) {
|
||||
assertCount(counts.failures[stage], `${label}.failures.${stage}`);
|
||||
}
|
||||
if (counts.captured + counts.failed + counts.pending !== counts.admitted) {
|
||||
throw new RangeError(`${label} does not conserve admitted executions`);
|
||||
}
|
||||
if (
|
||||
FAILURE_STAGES.reduce(
|
||||
(total, stage) => total + counts.failures[stage],
|
||||
0,
|
||||
) !== counts.failed
|
||||
) {
|
||||
throw new RangeError(`${label} failure stages do not conserve failures`);
|
||||
}
|
||||
}
|
||||
|
||||
function originMap(
|
||||
snapshot: LegacyShadowCaptureSnapshot,
|
||||
): Map<ExecutionOrigin, LegacyShadowCaptureOriginSnapshot> {
|
||||
if (snapshot.schema !== LEGACY_SHADOW_CAPTURE_SNAPSHOT_SCHEMA) {
|
||||
throw new TypeError('Legacy Shadow capture snapshot schema is unsupported');
|
||||
}
|
||||
if (!/^[0-9a-f-]{36}$/u.test(snapshot.epoch)) {
|
||||
throw new TypeError('Legacy Shadow capture epoch is invalid');
|
||||
}
|
||||
assertCount(snapshot.observedAtMs, 'snapshot.observedAtMs');
|
||||
const result = new Map<ExecutionOrigin, LegacyShadowCaptureOriginSnapshot>();
|
||||
for (const entry of snapshot.byOrigin) {
|
||||
if (result.has(entry.origin)) {
|
||||
throw new RangeError('Legacy Shadow capture snapshot repeats an origin');
|
||||
}
|
||||
assertConserved(entry, `snapshot.${entry.origin}`);
|
||||
result.set(entry.origin, entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function subtractCounts(
|
||||
before: LegacyShadowCaptureCounts,
|
||||
after: LegacyShadowCaptureCounts,
|
||||
label: string,
|
||||
): LegacyShadowCaptureCounts {
|
||||
const result = emptyCounts();
|
||||
for (const key of ['admitted', 'captured', 'failed'] as const) {
|
||||
result[key] = after[key] - before[key];
|
||||
assertCount(result[key], `${label}.${key}`);
|
||||
}
|
||||
if (before.pending !== 0) {
|
||||
throw new RangeError(`${label} starts with pending capture work`);
|
||||
}
|
||||
result.pending = after.pending;
|
||||
for (const stage of FAILURE_STAGES) {
|
||||
result.failures[stage] = after.failures[stage] - before.failures[stage];
|
||||
assertCount(result.failures[stage], `${label}.failures.${stage}`);
|
||||
}
|
||||
assertConserved(result, label);
|
||||
return result;
|
||||
}
|
||||
|
||||
export class LegacyShadowCaptureAuthority {
|
||||
private readonly epoch: string;
|
||||
private readonly counts = new Map<
|
||||
ExecutionOrigin,
|
||||
LegacyShadowCaptureCounts
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly clock: { now(): number } = { now: Date.now },
|
||||
epoch: string = randomUUID(),
|
||||
) {
|
||||
if (!/^[0-9a-f-]{36}$/u.test(epoch)) {
|
||||
throw new TypeError('Legacy Shadow capture epoch is invalid');
|
||||
}
|
||||
this.epoch = epoch;
|
||||
}
|
||||
|
||||
admit(origin: ExecutionOrigin): LegacyShadowCaptureAdmission {
|
||||
const counts = this.counts.get(origin) ?? emptyCounts();
|
||||
if (!this.counts.has(origin)) this.counts.set(origin, counts);
|
||||
counts.admitted += 1;
|
||||
counts.pending += 1;
|
||||
let settled = false;
|
||||
return Object.freeze({
|
||||
captured: () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
counts.pending -= 1;
|
||||
counts.captured += 1;
|
||||
},
|
||||
failed: (stage: LegacyShadowCaptureFailureStage) => {
|
||||
if (settled) return;
|
||||
if (!FAILURE_STAGES.includes(stage)) {
|
||||
throw new TypeError('Legacy Shadow capture failure stage is invalid');
|
||||
}
|
||||
settled = true;
|
||||
counts.pending -= 1;
|
||||
counts.failed += 1;
|
||||
counts.failures[stage] += 1;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
snapshot(origins: readonly ExecutionOrigin[]): LegacyShadowCaptureSnapshot {
|
||||
const observedAtMs = this.clock.now();
|
||||
assertCount(observedAtMs, 'snapshot.observedAtMs');
|
||||
const configured = [...new Set(origins)];
|
||||
return Object.freeze({
|
||||
schema: LEGACY_SHADOW_CAPTURE_SNAPSHOT_SCHEMA,
|
||||
epoch: this.epoch,
|
||||
observedAtMs,
|
||||
byOrigin: configured.map((origin) =>
|
||||
Object.freeze({
|
||||
origin,
|
||||
...cloneCounts(this.counts.get(origin) ?? emptyCounts()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createLegacyShadowCaptureReport(
|
||||
profile: 'edge' | 'standalone',
|
||||
origins: readonly ExecutionOrigin[],
|
||||
before: LegacyShadowCaptureSnapshot,
|
||||
after: LegacyShadowCaptureSnapshot,
|
||||
): LegacyShadowCaptureReport {
|
||||
if (profile !== 'edge' && profile !== 'standalone') {
|
||||
throw new TypeError('Legacy Shadow capture profile is invalid');
|
||||
}
|
||||
const configured = [...new Set(origins)];
|
||||
if (configured.length < 1 || configured.length > 7) {
|
||||
throw new RangeError('Legacy Shadow capture origin count is invalid');
|
||||
}
|
||||
if (before.epoch !== after.epoch) {
|
||||
throw new RangeError(
|
||||
'Legacy Shadow capture snapshots cross process epochs',
|
||||
);
|
||||
}
|
||||
if (before.observedAtMs >= after.observedAtMs) {
|
||||
throw new RangeError('Legacy Shadow capture window must be non-empty');
|
||||
}
|
||||
const beforeOrigins = originMap(before);
|
||||
const afterOrigins = originMap(after);
|
||||
if (
|
||||
beforeOrigins.size !== configured.length ||
|
||||
afterOrigins.size !== configured.length ||
|
||||
configured.some(
|
||||
(origin) => !beforeOrigins.has(origin) || !afterOrigins.has(origin),
|
||||
)
|
||||
) {
|
||||
throw new RangeError('Legacy Shadow capture origin coverage is incomplete');
|
||||
}
|
||||
const totals = emptyCounts();
|
||||
const byOrigin = configured.map((origin) => {
|
||||
const counts = subtractCounts(
|
||||
beforeOrigins.get(origin)!,
|
||||
afterOrigins.get(origin)!,
|
||||
`capture.${origin}`,
|
||||
);
|
||||
for (const key of ['admitted', 'captured', 'failed', 'pending'] as const) {
|
||||
totals[key] += counts[key];
|
||||
}
|
||||
for (const stage of FAILURE_STAGES) {
|
||||
totals.failures[stage] += counts.failures[stage];
|
||||
}
|
||||
return Object.freeze({ origin, ...counts });
|
||||
});
|
||||
assertConserved(totals, 'capture.totals');
|
||||
const assessment =
|
||||
totals.pending > 0
|
||||
? 'incomplete'
|
||||
: totals.failed > 0
|
||||
? 'failures_observed'
|
||||
: totals.admitted === 0
|
||||
? 'empty'
|
||||
: 'captured';
|
||||
const report: LegacyShadowCaptureReport = {
|
||||
schema: LEGACY_SHADOW_CAPTURE_REPORT_SCHEMA,
|
||||
profile,
|
||||
assessment,
|
||||
epoch: before.epoch,
|
||||
window: {
|
||||
basis: 'process_local_legacy_admission' as const,
|
||||
startInclusiveMs: before.observedAtMs,
|
||||
endExclusiveMs: after.observedAtMs,
|
||||
},
|
||||
configuredOriginCount: configured.length,
|
||||
totals,
|
||||
byOrigin,
|
||||
...(totals.admitted > 0
|
||||
? {
|
||||
capturePermille: Math.floor(
|
||||
(totals.captured * 1_000) / totals.admitted,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return Object.freeze(report);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ function classifyError(error: unknown): string {
|
||||
class SerialLegacyExecutionObservation
|
||||
implements TrackedLegacyExecutionObservation
|
||||
{
|
||||
private readonly capture: Promise<LegacyShadowRunReference | null>;
|
||||
private chain: Promise<LegacyShadowRunReference | null>;
|
||||
|
||||
constructor(
|
||||
@@ -72,10 +73,15 @@ class SerialLegacyExecutionObservation
|
||||
accepted: LegacyExecutionAcceptedFact,
|
||||
private readonly reporter: ShadowObservationReporter,
|
||||
) {
|
||||
this.chain = writer.accept(accepted).catch((error) => {
|
||||
this.capture = writer.accept(accepted).catch((error) => {
|
||||
this.report('accept', error);
|
||||
return null;
|
||||
});
|
||||
this.chain = this.capture;
|
||||
}
|
||||
|
||||
async captureSettled(): Promise<'captured' | 'failed'> {
|
||||
return (await this.capture) === null ? 'failed' : 'captured';
|
||||
}
|
||||
|
||||
spawned(fact: LegacyExecutionSpawnedFact): void {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import Logger from '../../loaders/logger';
|
||||
import type { LegacyShadowRunCorrelator } from '../application/legacyShadowRunCorrelator';
|
||||
import {
|
||||
LegacyShadowCaptureAuthority,
|
||||
type LegacyShadowCaptureAdmission,
|
||||
type LegacyShadowCaptureSnapshot,
|
||||
} from '../application/legacyShadowCaptureAuthority';
|
||||
import type { ExecutionOrigin } from '../domain/run';
|
||||
import type {
|
||||
LegacyExecutionCallbackFact,
|
||||
@@ -51,6 +56,12 @@ let configuredOrigins: ReadonlySet<ExecutionOrigin> | undefined;
|
||||
let defaultObserver: Promise<LegacyExecutionObserver> | undefined;
|
||||
let defaultCorrelator: Promise<LegacyShadowRunCorrelator> | undefined;
|
||||
const failureCounters = new Map<string, number>();
|
||||
let captureAuthority: LegacyShadowCaptureAuthority | undefined;
|
||||
|
||||
function getCaptureAuthority(): LegacyShadowCaptureAuthority {
|
||||
captureAuthority ??= new LegacyShadowCaptureAuthority();
|
||||
return captureAuthority;
|
||||
}
|
||||
const localRegistry = new LegacyExecutionRegistry({
|
||||
onOverflow() {
|
||||
incrementFailure('registry:capacity_exceeded');
|
||||
@@ -185,10 +196,27 @@ function getDefaultCorrelator(): Promise<LegacyShadowRunCorrelator> {
|
||||
function beginFailOpen(
|
||||
observer: LegacyExecutionObserver,
|
||||
accepted: LegacyExecutionAcceptedFact,
|
||||
capture?: LegacyShadowCaptureAdmission,
|
||||
): LegacyExecutionObservation {
|
||||
try {
|
||||
return observer.begin(accepted);
|
||||
const observation = observer.begin(accepted);
|
||||
if (capture) {
|
||||
const settled = observation.captureSettled?.();
|
||||
if (!settled) {
|
||||
capture.failed('observer');
|
||||
} else {
|
||||
void settled.then(
|
||||
(outcome) =>
|
||||
outcome === 'captured'
|
||||
? capture.captured()
|
||||
: capture.failed('accept'),
|
||||
() => capture.failed('accept'),
|
||||
);
|
||||
}
|
||||
}
|
||||
return observation;
|
||||
} catch {
|
||||
capture?.failed('observer');
|
||||
incrementFailure(`${accepted.origin}:begin:failed`);
|
||||
try {
|
||||
Logger.warn(
|
||||
@@ -204,6 +232,7 @@ function beginFailOpen(
|
||||
function createAcceptedFactFailOpen(
|
||||
origin: ExecutionOrigin,
|
||||
createFact: LegacyExecutionAcceptedFactFactory,
|
||||
capture?: LegacyShadowCaptureAdmission,
|
||||
): LegacyExecutionAcceptedFact | null {
|
||||
try {
|
||||
const fact = createFact();
|
||||
@@ -212,6 +241,7 @@ function createAcceptedFactFailOpen(
|
||||
}
|
||||
return fact;
|
||||
} catch {
|
||||
capture?.failed('fact');
|
||||
incrementFailure(`${origin}:fact:failed`);
|
||||
try {
|
||||
Logger.warn(`[ql3-shadow] fact creation failed origin=${origin}`);
|
||||
@@ -225,10 +255,14 @@ function createAcceptedFactFailOpen(
|
||||
function deferredObservation(
|
||||
observer: Promise<LegacyExecutionObserver>,
|
||||
accepted: LegacyExecutionAcceptedFact,
|
||||
capture: LegacyShadowCaptureAdmission,
|
||||
): LegacyExecutionObservation {
|
||||
const delegate = observer
|
||||
.then((value) => beginFailOpen(value, accepted))
|
||||
.catch(() => NOOP_OBSERVATION);
|
||||
.then((value) => beginFailOpen(value, accepted, capture))
|
||||
.catch(() => {
|
||||
capture.failed('initialization');
|
||||
return NOOP_OBSERVATION;
|
||||
});
|
||||
const enqueue = <T>(
|
||||
operation: (observation: LegacyExecutionObservation, fact: T) => void,
|
||||
fact: T,
|
||||
@@ -266,11 +300,12 @@ export function observeLegacyExecution(
|
||||
: NOOP_OBSERVATION;
|
||||
}
|
||||
if (!readConfiguredOrigins().has(origin)) return undefined;
|
||||
const fact = createAcceptedFactFailOpen(origin, createFact);
|
||||
const capture = getCaptureAuthority().admit(origin);
|
||||
const fact = createAcceptedFactFailOpen(origin, createFact, capture);
|
||||
return fact
|
||||
? localRegistry.register(
|
||||
fact,
|
||||
deferredObservation(getDefaultObserver(), fact),
|
||||
deferredObservation(getDefaultObserver(), fact, capture),
|
||||
)
|
||||
: NOOP_OBSERVATION;
|
||||
}
|
||||
@@ -302,9 +337,10 @@ export function observeLegacyShellExecutionCallback(
|
||||
: NOOP_OBSERVATION;
|
||||
} else {
|
||||
if (!readConfiguredOrigins().has(origin)) return undefined;
|
||||
const fact = createAcceptedFactFailOpen(origin, createFact);
|
||||
const capture = getCaptureAuthority().admit(origin);
|
||||
const fact = createAcceptedFactFailOpen(origin, createFact, capture);
|
||||
observation = fact
|
||||
? deferredObservation(getDefaultObserver(), fact)
|
||||
? deferredObservation(getDefaultObserver(), fact, capture)
|
||||
: NOOP_OBSERVATION;
|
||||
}
|
||||
|
||||
@@ -415,3 +451,9 @@ export function shadowBridgeFailureSnapshot(): Readonly<
|
||||
> {
|
||||
return Object.fromEntries(failureCounters);
|
||||
}
|
||||
|
||||
export function legacyShadowCaptureSnapshot(
|
||||
origins: readonly ExecutionOrigin[] = configuredLegacyShadowOrigins(),
|
||||
): LegacyShadowCaptureSnapshot {
|
||||
return getCaptureAuthority().snapshot(origins);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
export const LEGACY_SHADOW_PRIMARY_GATE_SCHEMA =
|
||||
'qinglong/legacy-shadow-primary-gate@v1';
|
||||
|
||||
export type LegacyShadowPrimaryGateViolation =
|
||||
| 'capture_schema_invalid'
|
||||
| 'capture_profile_mismatch'
|
||||
| 'capture_not_qualified'
|
||||
| 'capture_origin_coverage_invalid'
|
||||
| 'capture_window_invalid'
|
||||
| 'capture_sample_budget_invalid'
|
||||
| 'capture_conservation_invalid'
|
||||
| 'startup_not_converged'
|
||||
| 'terminal_schema_invalid'
|
||||
| 'terminal_profile_mismatch'
|
||||
| 'terminal_window_mismatch'
|
||||
| 'terminal_coverage_invalid'
|
||||
| 'terminal_not_matched'
|
||||
| 'resource_schema_invalid'
|
||||
| 'resource_profile_mismatch'
|
||||
| 'resource_not_qualified'
|
||||
| 'resource_not_compiled_full_rollback';
|
||||
|
||||
export interface LegacyShadowPrimaryGateReceipt {
|
||||
schema: typeof LEGACY_SHADOW_PRIMARY_GATE_SCHEMA;
|
||||
schemaVersion: 1;
|
||||
profile: 'edge' | 'standalone';
|
||||
origin: 'manual';
|
||||
generatedAtMs: number;
|
||||
assessment: 'eligible' | 'ineligible';
|
||||
window: {
|
||||
startInclusiveMs: number;
|
||||
endExclusiveMs: number;
|
||||
};
|
||||
counts: {
|
||||
admitted: number;
|
||||
captured: number;
|
||||
terminalScanned: number;
|
||||
terminalMatched: number;
|
||||
};
|
||||
evidence: {
|
||||
captureSha256: string;
|
||||
terminalSha256: string;
|
||||
resourceSha256: string;
|
||||
};
|
||||
sources: {
|
||||
capture: unknown;
|
||||
terminal: unknown;
|
||||
resource: unknown;
|
||||
};
|
||||
violations: readonly LegacyShadowPrimaryGateViolation[];
|
||||
}
|
||||
|
||||
export interface LegacyShadowPrimaryGateInput {
|
||||
profile: 'edge' | 'standalone';
|
||||
generatedAtMs: number;
|
||||
capture: unknown;
|
||||
terminal: unknown;
|
||||
resource: unknown;
|
||||
}
|
||||
|
||||
const SAMPLE_BUDGETS = Object.freeze({
|
||||
edge: { minimum: 8, maximum: 8 },
|
||||
standalone: { minimum: 32, maximum: 128 },
|
||||
});
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
||||
|
||||
function object(value: unknown): Record<string, any> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, any>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function safeCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function add(
|
||||
violations: LegacyShadowPrimaryGateViolation[],
|
||||
violation: LegacyShadowPrimaryGateViolation,
|
||||
): void {
|
||||
if (!violations.includes(violation)) violations.push(violation);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'string' || typeof value === 'boolean') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value))
|
||||
throw new TypeError('Evidence number is invalid');
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(canonicalJson).join(',')}]`;
|
||||
}
|
||||
const record = object(value);
|
||||
if (!record) throw new TypeError('Evidence contains a non-JSON value');
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
export function legacyShadowPrimaryEvidenceSha256(value: unknown): string {
|
||||
return createHash('sha256').update(canonicalJson(value)).digest('hex');
|
||||
}
|
||||
|
||||
export function createLegacyShadowPrimaryGateReceipt(
|
||||
input: LegacyShadowPrimaryGateInput,
|
||||
): LegacyShadowPrimaryGateReceipt {
|
||||
if (input.profile !== 'edge' && input.profile !== 'standalone') {
|
||||
throw new TypeError('Primary gate profile is invalid');
|
||||
}
|
||||
if (!safeCount(input.generatedAtMs)) {
|
||||
throw new TypeError('Primary gate timestamp is invalid');
|
||||
}
|
||||
const sources = {
|
||||
capture: JSON.parse(canonicalJson(input.capture)),
|
||||
terminal: JSON.parse(canonicalJson(input.terminal)),
|
||||
resource: JSON.parse(canonicalJson(input.resource)),
|
||||
};
|
||||
const evidence = {
|
||||
captureSha256: legacyShadowPrimaryEvidenceSha256(sources.capture),
|
||||
terminalSha256: legacyShadowPrimaryEvidenceSha256(sources.terminal),
|
||||
resourceSha256: legacyShadowPrimaryEvidenceSha256(sources.resource),
|
||||
};
|
||||
const violations: LegacyShadowPrimaryGateViolation[] = [];
|
||||
const captureEvidence = object(sources.capture);
|
||||
const capture = object(captureEvidence?.capture);
|
||||
const startup = object(captureEvidence?.startup);
|
||||
const captureWindow = object(capture?.window);
|
||||
const captureTotals = object(capture?.totals);
|
||||
const captureByOriginValue = capture?.byOrigin;
|
||||
const captureByOrigin = Array.isArray(captureByOriginValue)
|
||||
? captureByOriginValue
|
||||
: [];
|
||||
const admittedValue = captureTotals?.admitted;
|
||||
const capturedValue = captureTotals?.captured;
|
||||
const admitted = safeCount(admittedValue) ? admittedValue : 0;
|
||||
const captured = safeCount(capturedValue) ? capturedValue : 0;
|
||||
|
||||
if (
|
||||
captureEvidence?.schema !== 'qinglong/legacy-shadow-capture-evidence@v1' ||
|
||||
capture?.schema !== 'qinglong/legacy-shadow-capture-report@v1'
|
||||
) {
|
||||
add(violations, 'capture_schema_invalid');
|
||||
}
|
||||
if (
|
||||
captureEvidence?.profile !== input.profile ||
|
||||
capture?.profile !== input.profile
|
||||
) {
|
||||
add(violations, 'capture_profile_mismatch');
|
||||
}
|
||||
if (
|
||||
object(captureEvidence?.qualification)?.passed !== true ||
|
||||
capture?.assessment !== 'captured' ||
|
||||
capture?.capturePermille !== 1_000
|
||||
) {
|
||||
add(violations, 'capture_not_qualified');
|
||||
}
|
||||
if (
|
||||
capture?.configuredOriginCount !== 1 ||
|
||||
captureByOrigin.length !== 1 ||
|
||||
object(captureByOrigin[0])?.origin !== 'manual' ||
|
||||
startup?.configuredOriginCount !== 1 ||
|
||||
!Array.isArray(startup?.byOrigin) ||
|
||||
startup.byOrigin.length !== 1 ||
|
||||
object(startup.byOrigin[0])?.origin !== 'manual'
|
||||
) {
|
||||
add(violations, 'capture_origin_coverage_invalid');
|
||||
}
|
||||
const startInclusiveMs = captureWindow?.startInclusiveMs;
|
||||
const endExclusiveMs = captureWindow?.endExclusiveMs;
|
||||
if (
|
||||
captureWindow?.basis !== 'process_local_legacy_admission' ||
|
||||
!safeCount(startInclusiveMs) ||
|
||||
!safeCount(endExclusiveMs) ||
|
||||
startInclusiveMs >= endExclusiveMs ||
|
||||
endExclusiveMs > input.generatedAtMs
|
||||
) {
|
||||
add(violations, 'capture_window_invalid');
|
||||
}
|
||||
const budget = SAMPLE_BUDGETS[input.profile];
|
||||
if (admitted < budget.minimum || admitted > budget.maximum) {
|
||||
add(violations, 'capture_sample_budget_invalid');
|
||||
}
|
||||
if (
|
||||
!safeCount(captureTotals?.failed) ||
|
||||
!safeCount(captureTotals?.pending) ||
|
||||
captured !== admitted ||
|
||||
captureTotals?.failed !== 0 ||
|
||||
captureTotals?.pending !== 0
|
||||
) {
|
||||
add(violations, 'capture_conservation_invalid');
|
||||
}
|
||||
if (
|
||||
startup?.schema !== 'qinglong/legacy-shadow-startup-difference-report@v1' ||
|
||||
startup?.profile !== input.profile ||
|
||||
startup?.assessment !== 'converged' ||
|
||||
object(startup?.coverage)?.remaining !== false
|
||||
) {
|
||||
add(violations, 'startup_not_converged');
|
||||
}
|
||||
|
||||
const terminal = object(sources.terminal);
|
||||
const terminalWindow = object(terminal?.window);
|
||||
const terminalCoverage = object(terminal?.coverage);
|
||||
const terminalCounts = object(terminal?.counts);
|
||||
const terminalByOriginValue = terminal?.byOrigin;
|
||||
const terminalByOrigin = Array.isArray(terminalByOriginValue)
|
||||
? terminalByOriginValue
|
||||
: [];
|
||||
const terminalScannedValue = terminal?.scanned;
|
||||
const terminalMatchedValue = terminalCounts?.matched;
|
||||
const terminalScanned = safeCount(terminalScannedValue)
|
||||
? terminalScannedValue
|
||||
: 0;
|
||||
const terminalMatched = safeCount(terminalMatchedValue)
|
||||
? terminalMatchedValue
|
||||
: 0;
|
||||
const terminalObservedAtMs = terminal?.observedAtMs;
|
||||
if (
|
||||
terminal?.schema !== 'qinglong/legacy-shadow-terminal-difference-report@v1'
|
||||
) {
|
||||
add(violations, 'terminal_schema_invalid');
|
||||
}
|
||||
if (terminal?.profile !== input.profile) {
|
||||
add(violations, 'terminal_profile_mismatch');
|
||||
}
|
||||
if (
|
||||
terminalWindow?.startInclusiveMs !== startInclusiveMs ||
|
||||
terminalWindow?.endExclusiveMs !== endExclusiveMs ||
|
||||
terminalWindow?.closed !== true ||
|
||||
!safeCount(terminalObservedAtMs) ||
|
||||
terminalObservedAtMs > input.generatedAtMs
|
||||
) {
|
||||
add(violations, 'terminal_window_mismatch');
|
||||
}
|
||||
if (
|
||||
terminalCoverage?.direction !== 'shadow_to_legacy' ||
|
||||
terminalCoverage?.cohort !== 'legacy_owned_shadow_runs' ||
|
||||
terminalCoverage?.legacyWithoutShadow !== 'not_measured' ||
|
||||
terminalByOrigin.length !== 1 ||
|
||||
object(terminalByOrigin[0])?.origin !== 'manual' ||
|
||||
object(terminalByOrigin[0])?.scanned !== terminalScanned
|
||||
) {
|
||||
add(violations, 'terminal_coverage_invalid');
|
||||
}
|
||||
if (
|
||||
terminal?.assessment !== 'matched' ||
|
||||
terminal?.remaining !== false ||
|
||||
terminal?.evidenceComplete !== true ||
|
||||
terminal?.terminalAgreementPermille !== 1_000 ||
|
||||
terminal?.fullyComparablePermille !== 1_000 ||
|
||||
terminalScanned !== captured ||
|
||||
terminalMatched !== captured
|
||||
) {
|
||||
add(violations, 'terminal_not_matched');
|
||||
}
|
||||
|
||||
const resource = object(sources.resource);
|
||||
const workload = object(resource?.workload);
|
||||
const rollback = object(resource?.rollback);
|
||||
const qualification = object(resource?.qualification);
|
||||
if (
|
||||
resource?.schemaVersion !== 1 ||
|
||||
resource?.fixture !== 'qinglong/legacy-shadow-resource-rollback-evidence@v1'
|
||||
) {
|
||||
add(violations, 'resource_schema_invalid');
|
||||
}
|
||||
if (resource?.profile !== input.profile) {
|
||||
add(violations, 'resource_profile_mismatch');
|
||||
}
|
||||
if (
|
||||
qualification?.passed !== true ||
|
||||
qualification?.violations?.length !== 0
|
||||
) {
|
||||
add(violations, 'resource_not_qualified');
|
||||
}
|
||||
if (
|
||||
workload?.mode !== 'full' ||
|
||||
workload?.runtime !== 'compiled_backend' ||
|
||||
rollback?.performed !== true ||
|
||||
rollback?.legacyContinued !== true ||
|
||||
rollback?.shadowWritesStopped !== true ||
|
||||
rollback?.databaseIntegrity !== 'ok'
|
||||
) {
|
||||
add(violations, 'resource_not_compiled_full_rollback');
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schema: LEGACY_SHADOW_PRIMARY_GATE_SCHEMA,
|
||||
schemaVersion: 1,
|
||||
profile: input.profile,
|
||||
origin: 'manual',
|
||||
generatedAtMs: input.generatedAtMs,
|
||||
assessment: violations.length === 0 ? 'eligible' : 'ineligible',
|
||||
window: {
|
||||
startInclusiveMs: safeCount(startInclusiveMs) ? startInclusiveMs : 0,
|
||||
endExclusiveMs: safeCount(endExclusiveMs) ? endExclusiveMs : 0,
|
||||
},
|
||||
counts: { admitted, captured, terminalScanned, terminalMatched },
|
||||
evidence,
|
||||
sources,
|
||||
violations,
|
||||
});
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const wanted = [...expected].sort();
|
||||
if (
|
||||
keys.length !== wanted.length ||
|
||||
keys.some((key, index) => key !== wanted[index])
|
||||
) {
|
||||
throw new TypeError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLegacyShadowPrimaryGateReceipt(
|
||||
value: unknown,
|
||||
): LegacyShadowPrimaryGateReceipt {
|
||||
const receipt = object(value);
|
||||
if (!receipt) throw new TypeError('Primary gate receipt must be an object');
|
||||
assertExactKeys(
|
||||
receipt,
|
||||
[
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'profile',
|
||||
'origin',
|
||||
'generatedAtMs',
|
||||
'assessment',
|
||||
'window',
|
||||
'counts',
|
||||
'evidence',
|
||||
'sources',
|
||||
'violations',
|
||||
],
|
||||
'receipt',
|
||||
);
|
||||
const window = object(receipt.window);
|
||||
const counts = object(receipt.counts);
|
||||
const evidence = object(receipt.evidence);
|
||||
const sources = object(receipt.sources);
|
||||
if (!window || !counts || !evidence || !sources) {
|
||||
throw new TypeError('Primary gate receipt nested shape is invalid');
|
||||
}
|
||||
assertExactKeys(
|
||||
window,
|
||||
['startInclusiveMs', 'endExclusiveMs'],
|
||||
'receipt.window',
|
||||
);
|
||||
assertExactKeys(
|
||||
sources,
|
||||
['capture', 'terminal', 'resource'],
|
||||
'receipt.sources',
|
||||
);
|
||||
assertExactKeys(
|
||||
counts,
|
||||
['admitted', 'captured', 'terminalScanned', 'terminalMatched'],
|
||||
'receipt.counts',
|
||||
);
|
||||
assertExactKeys(
|
||||
evidence,
|
||||
['captureSha256', 'terminalSha256', 'resourceSha256'],
|
||||
'receipt.evidence',
|
||||
);
|
||||
if (
|
||||
receipt.schema !== LEGACY_SHADOW_PRIMARY_GATE_SCHEMA ||
|
||||
receipt.schemaVersion !== 1 ||
|
||||
!['edge', 'standalone'].includes(receipt.profile) ||
|
||||
receipt.origin !== 'manual' ||
|
||||
!safeCount(receipt.generatedAtMs) ||
|
||||
!['eligible', 'ineligible'].includes(receipt.assessment) ||
|
||||
!safeCount(window.startInclusiveMs) ||
|
||||
!safeCount(window.endExclusiveMs) ||
|
||||
window.startInclusiveMs >= window.endExclusiveMs ||
|
||||
!Object.values(counts).every(safeCount) ||
|
||||
!Object.values(evidence).every(
|
||||
(digest) => typeof digest === 'string' && SHA256_PATTERN.test(digest),
|
||||
) ||
|
||||
!Array.isArray(receipt.violations) ||
|
||||
receipt.violations.some(
|
||||
(violation: unknown) =>
|
||||
typeof violation !== 'string' || violation.length > 64,
|
||||
) ||
|
||||
(receipt.assessment === 'eligible' && receipt.violations.length !== 0) ||
|
||||
(receipt.assessment === 'ineligible' && receipt.violations.length === 0)
|
||||
) {
|
||||
throw new TypeError('Primary gate receipt is invalid');
|
||||
}
|
||||
const recomputed = createLegacyShadowPrimaryGateReceipt({
|
||||
profile: receipt.profile as 'edge' | 'standalone',
|
||||
generatedAtMs: receipt.generatedAtMs,
|
||||
capture: sources.capture,
|
||||
terminal: sources.terminal,
|
||||
resource: sources.resource,
|
||||
});
|
||||
for (const field of [
|
||||
'assessment',
|
||||
'window',
|
||||
'counts',
|
||||
'evidence',
|
||||
'violations',
|
||||
] as const) {
|
||||
if (canonicalJson(receipt[field]) !== canonicalJson(recomputed[field])) {
|
||||
throw new TypeError(`Primary gate receipt ${field} was not reproduced`);
|
||||
}
|
||||
}
|
||||
return receipt as unknown as LegacyShadowPrimaryGateReceipt;
|
||||
}
|
||||
import { createHash } from 'crypto';
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type RuntimeRolloutConfig,
|
||||
} from './runtimeRollout';
|
||||
|
||||
export const RUNTIME_ROLLOUT_MANIFEST_VERSION = 1;
|
||||
export const RUNTIME_ROLLOUT_MANIFEST_VERSION = 2;
|
||||
export const MAX_RUNTIME_ROLLOUT_APPROVAL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const REQUIRED_RUNTIME_ROLLOUT_GATES = [
|
||||
@@ -33,6 +33,12 @@ export interface EnabledRuntimeRolloutManifest {
|
||||
approvedAtMs: number;
|
||||
expiresAtMs: number;
|
||||
rollbackPlanRef: string;
|
||||
primaryGate: {
|
||||
schema: 'qinglong/legacy-shadow-primary-gate-reference@v1';
|
||||
origin: 'manual';
|
||||
receiptFile: string;
|
||||
receiptSha256: string;
|
||||
};
|
||||
rollout: RuntimeRolloutConfig;
|
||||
gates: Record<RuntimeRolloutGate, 'passed'>;
|
||||
}
|
||||
@@ -46,12 +52,15 @@ export interface RuntimeRolloutManifestDecision {
|
||||
policy: RuntimeRolloutPolicy;
|
||||
}
|
||||
|
||||
const MANUAL_ORIGIN: ExecutionOrigin = 'manual';
|
||||
const MANUAL_ORIGIN = 'manual' as const satisfies ExecutionOrigin;
|
||||
const ALLOWED_MANUAL_MODES = new Set<CompatibilityMode>([
|
||||
'off',
|
||||
'shadow',
|
||||
'primary',
|
||||
]);
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
||||
const RECEIPT_FILE_PATTERN =
|
||||
/^(?=.{1,128}$)(?!\.)(?!.*\.\.)(?:[A-Za-z0-9][A-Za-z0-9._-]*)\.json$/u;
|
||||
|
||||
function asObject(value: unknown, name: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
@@ -139,6 +148,7 @@ export function parseRuntimeRolloutManifest(
|
||||
'approvedAtMs',
|
||||
'expiresAtMs',
|
||||
'rollbackPlanRef',
|
||||
'primaryGate',
|
||||
'rollout',
|
||||
'gates',
|
||||
],
|
||||
@@ -201,6 +211,39 @@ export function parseRuntimeRolloutManifest(
|
||||
}
|
||||
}
|
||||
|
||||
const primaryGateObject = asObject(
|
||||
object.primaryGate,
|
||||
'manifest.primaryGate',
|
||||
);
|
||||
assertExactKeys(
|
||||
primaryGateObject,
|
||||
['schema', 'origin', 'receiptFile', 'receiptSha256'],
|
||||
'manifest.primaryGate',
|
||||
);
|
||||
if (
|
||||
primaryGateObject.schema !==
|
||||
'qinglong/legacy-shadow-primary-gate-reference@v1' ||
|
||||
primaryGateObject.origin !== MANUAL_ORIGIN
|
||||
) {
|
||||
throw new TypeError('manifest.primaryGate authority is invalid');
|
||||
}
|
||||
const receiptFile = boundedString(
|
||||
primaryGateObject.receiptFile,
|
||||
'manifest.primaryGate.receiptFile',
|
||||
128,
|
||||
);
|
||||
if (!RECEIPT_FILE_PATTERN.test(receiptFile)) {
|
||||
throw new TypeError('manifest.primaryGate.receiptFile is invalid');
|
||||
}
|
||||
const receiptSha256 = boundedString(
|
||||
primaryGateObject.receiptSha256,
|
||||
'manifest.primaryGate.receiptSha256',
|
||||
64,
|
||||
);
|
||||
if (!SHA256_PATTERN.test(receiptSha256)) {
|
||||
throw new TypeError('manifest.primaryGate.receiptSha256 is invalid');
|
||||
}
|
||||
|
||||
const rollout: RuntimeRolloutConfig = {
|
||||
defaultMode: 'off',
|
||||
origins: { manual: origins.manual as CompatibilityMode },
|
||||
@@ -218,6 +261,12 @@ export function parseRuntimeRolloutManifest(
|
||||
'manifest.rollbackPlanRef',
|
||||
512,
|
||||
),
|
||||
primaryGate: {
|
||||
schema: 'qinglong/legacy-shadow-primary-gate-reference@v1',
|
||||
origin: MANUAL_ORIGIN,
|
||||
receiptFile,
|
||||
receiptSha256,
|
||||
},
|
||||
rollout,
|
||||
gates: Object.fromEntries(
|
||||
REQUIRED_RUNTIME_ROLLOUT_GATES.map((gate) => [gate, 'passed']),
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface LegacyExecutionCancelledFact {
|
||||
}
|
||||
|
||||
export interface LegacyExecutionObservation {
|
||||
captureSettled?(): Promise<'captured' | 'failed'>;
|
||||
spawned(fact: LegacyExecutionSpawnedFact): void;
|
||||
running(fact: LegacyExecutionRunningFact): void;
|
||||
startFailed(fact: LegacyExecutionStartFailedFact): void;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeRolloutPolicy } from '../domain/runtimeRollout';
|
||||
import type { RuntimeRolloutManifest } from '../domain/runtimeRolloutManifest';
|
||||
import type { LegacyShadowPrimaryGateReceipt } from '../domain/legacyShadowPrimaryGate';
|
||||
|
||||
export type RuntimeRolloutLoadStatus =
|
||||
| 'missing'
|
||||
@@ -19,7 +20,9 @@ export interface RuntimeRolloutLoadAudit {
|
||||
| 'FILE_READ_FAILED'
|
||||
| 'FILE_TOO_LARGE'
|
||||
| 'INVALID_JSON'
|
||||
| 'INVALID_MANIFEST';
|
||||
| 'INVALID_MANIFEST'
|
||||
| 'PRIMARY_GATE_READ_FAILED'
|
||||
| 'PRIMARY_GATE_INVALID';
|
||||
}
|
||||
|
||||
export interface RuntimeRolloutLoadResult {
|
||||
@@ -27,6 +30,7 @@ export interface RuntimeRolloutLoadResult {
|
||||
policy: RuntimeRolloutPolicy;
|
||||
audit: RuntimeRolloutLoadAudit;
|
||||
manifest?: RuntimeRolloutManifest;
|
||||
primaryGateReceipt?: LegacyShadowPrimaryGateReceipt;
|
||||
}
|
||||
|
||||
export interface RuntimeRolloutLoader {
|
||||
|
||||
@@ -11,6 +11,24 @@
|
||||
|
||||
最新增量证据(2026-08-19):
|
||||
|
||||
- D-360/ADR-0453(已接受;首次真实目标实例 manual canary bundle 待执行):不再用 2.x RunningInstance 推测 Legacy→Shadow 分母,也不再让 Primary
|
||||
manifest 只信任维护者填写的 `"passed"`。默认 Legacy bridge 为每个已启用 origin admission 分配 process-epoch token,固定守恒
|
||||
`captured + failed + pending = admitted`,失败只分 `fact/observer/initialization/accept`;只有真实 Shadow writer accept 才结算 captured,Legacy
|
||||
spawn/返回值与失败开放语义不变。HTTP worker 在 startup reconciliation 后装配无 timer/watcher 的一次性 exporter;仅显式配置
|
||||
`QL3_SHADOW_CAPTURE_EVIDENCE_FILE` 时,干净 shutdown 才以 `0600`、no-replace 写一份不含 Run/Cron/Attempt/PID/task/path 的 capture+startup evidence。
|
||||
`gate:legacy-shadow-primary:ql3` 当前只裁决 manual:Edge 必须精确 8 条、Standalone 为 32–128 条;capture window 与 closed terminal audit 必须完全一致,
|
||||
captured/scanned/matched 相等、两种 agreement 均为 1000/1000,并绑定同 Profile 的 compiled-backend full rollback/resource report。Primary gate v1
|
||||
bundle 嵌入三份低敏 source report 及 canonical SHA-256;rollout manifest 升为 v2,loader 通过同目录 basename+digest 读取 bundle 后重新计算 source digest 和
|
||||
全部门禁,不信任 CLI 的 eligible 字段,且复验 receipt 先于审批、Profile 与实际部署一致。v1 enabled、缺失/篡改/ineligible bundle、样本不足、pending/failure、
|
||||
terminal 漂移、audit-only rollback 或 symlink 均失败关闭;disabled/off 保持零重组件。真实 compiled backend `ScheduleService.runTask` 已让默认
|
||||
system bridge 产生 admitted/captured `1/1`、failed/pending `0/0`,只证明真实结算链,不冒充正式 manual canary。该能力不自动写 manifest、不启用
|
||||
Primary、不授权其他 origin,也不证明物理路由、flash、断电或跨主机签名。阶段门已重跑:聚焦 `48/48`、Legacy/Shadow 串行扩展
|
||||
`117/117`、资源/回滚专项 `4/4`、`build:back`、完整 backend `1,469 pass / 0 fail / 2 conditional skip`、18-package clean build/test、四项可执行
|
||||
架构审计与 `14/14` artifact audit 全部通过。14 档产物字节保持 D-358 基线。Linux arm64 router stress 维持 `128 MiB / 0.5 CPU / 0 swap / 64 PID`,
|
||||
cgroup peak `95,113,216` bytes;Edge release 维持 `256 MiB / 1 CPU / 0 swap / 128 PID`,13 个 workload 全绿、peak `144,740,352` bytes,
|
||||
两档 `memory.events max/oom/oom_kill` 增量均为 0。D-360 不改 PostgreSQL schema/migration、依赖树或 Kubernetes 拓扑,故不重跑 PostgreSQL HA;
|
||||
相邻 D-359 的 `142/142` 与 timeline `1→2` 只保留为既有证据,不冒充本阶段新结果。
|
||||
|
||||
- D-359/ADR-0451、ADR-0452(已接受):为 D-358 的闭合窗口审计增加真实 compiled-backend 资源与关闭回滚门。证据 schema 固定为
|
||||
`qinglong/legacy-shadow-resource-rollback-evidence@v1`;edge 为 8 candidates/1 page,standalone 为 128 candidates/4 pages,8 个样本分别精确执行
|
||||
16/64 条查询,SQLite database/WAL/SHM/journal 的 logical/allocated bytes 和 file count 前后必须一致。full 模式经过独立进程重启:enabled 与 off
|
||||
@@ -8290,7 +8308,10 @@ Primary 不使用普通环境变量或宽泛全局开关启用。孵化配置面
|
||||
- 文件缺失、不可读、超过 64 KiB、JSON 损坏、未知字段、过期审批或 gate 不完整时 fail-closed 为 `off`。
|
||||
- `defaultMode` 必须保持 `off`;当前 manifest 只允许声明 `manual`,不得用一个配置隐式接管 boot、定时、gRPC 或其他来源。
|
||||
- 启用记录必须包含有界 revision、审批人、审批起止时间和 rollback plan 引用;审批窗口最长 30 天。
|
||||
- `durableCancellation`、`startupReconciliation`、`atomicLegacyProjection`、`rollbackDrill`、`edgeBudget` 必须全部为 `passed`。
|
||||
- `durableCancellation`、`startupReconciliation`、`atomicLegacyProjection`、`rollbackDrill`、`edgeBudget` 必须全部为 `passed`;从 schema v2 起这些声明不能
|
||||
代替 Primary evidence bundle。
|
||||
- enabled manifest 必须绑定同 config 目录的 `qinglong/legacy-shadow-primary-gate@v1` bundle basename 与 SHA-256。loader 必须 no-follow 读取、重算 embedded
|
||||
capture/startup、terminal、resource source digest 和完整 eligibility,并复验 manual origin、实际 Profile 与审批时序;只验证 receipt 自称 eligible 不成立。
|
||||
- 审计只记录路径、revision、判定、时间和源文件 SHA-256,不记录完整配置内容;接受判定必须在安装 owner router 前可观测,安装后审计失败必须撤销 router。
|
||||
- edge 不启动文件 watcher。配置只在显式 bootstrap/reload 时读取,禁用时不创建 router、timer、连接或后台任务。
|
||||
|
||||
@@ -8298,13 +8319,19 @@ Primary 不使用普通环境变量或宽泛全局开关启用。孵化配置面
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"schemaVersion": 2,
|
||||
"revision": "manual-primary-canary-1",
|
||||
"enabled": true,
|
||||
"approvedBy": "operator:admin",
|
||||
"approvedAtMs": 1750000000000,
|
||||
"expiresAtMs": 1750086400000,
|
||||
"rollbackPlanRef": "docs/runbooks/disable-primary.md",
|
||||
"primaryGate": {
|
||||
"schema": "qinglong/legacy-shadow-primary-gate-reference@v1",
|
||||
"origin": "manual",
|
||||
"receiptFile": "manual-primary-gate.json",
|
||||
"receiptSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"rollout": {
|
||||
"defaultMode": "off",
|
||||
"origins": { "manual": "primary" },
|
||||
@@ -8320,7 +8347,13 @@ Primary 不使用普通环境变量或宽泛全局开关启用。孵化配置面
|
||||
}
|
||||
```
|
||||
|
||||
当前 `next` 已在 HTTP worker 接入轻量 manifest bootstrap。文件缺失、disabled、rejected 或 manual 非 primary 时不会加载完整 Runtime stack、创建 router 或启动 timer;只有 accepted 且所有 gate 为 passed 的 manual primary 配置才惰性加载真实组件。激活顺序固定为:记录 selected 审计、完整有界 startup reconciliation、记录 reconciled 审计、启动 timeout intent lifecycle、启动 cancel dispatch lifecycle、安装 router、记录 activated;任何一步失败都会撤销 router,并按 producer → consumer 顺序停止 lifecycle。HTTP shutdown 与监听失败也会执行有界清理。`QL_DEPLOYMENT_PROFILE` 未配置时为 standalone,非法值或在 cluster-control/worker 中误装本机 SQLite Primary 时 fail closed。完整 completion/log 重启恢复、固定物理 edge Gate、配置写入/用户可见状态和操作回滚演练完成后仍需单独评审。
|
||||
当前 `next` 已在 HTTP worker 接入轻量 manifest bootstrap。文件缺失、disabled、rejected、manual 非 primary、v1 enabled 或 Primary bundle
|
||||
缺失/篡改/不可重放时不会加载完整 Runtime stack、创建 router 或启动 timer;只有 schema v2、五项 capability gate 完整,且 bundle 经 loader 独立重算为 eligible
|
||||
的 manual primary 配置才惰性加载真实组件。激活顺序固定为:重放 evidence→复验实际 Profile→记录 selected 审计→完整有界 startup reconciliation→记录
|
||||
reconciled 审计→启动 completion/timeout/cancel lifecycle→安装 router→记录 activated;任何一步失败都会撤销 router,并按 producer → consumer 顺序停止
|
||||
lifecycle。HTTP shutdown 与监听失败也会执行有界清理。`QL_DEPLOYMENT_PROFILE` 未配置时为 standalone,非法值、receipt Profile 不一致或在
|
||||
cluster-control/worker 中误装本机 SQLite Primary 时 fail closed。首次目标实例 manual canary、固定物理 edge Gate、配置写入/用户可见状态和操作回滚演练完成后
|
||||
仍需单独评审。
|
||||
|
||||
## 26. 交付阶段
|
||||
|
||||
@@ -9143,7 +9176,7 @@ flowchart LR
|
||||
| PR-1 Run Schema | Incubating | Run/RunAttempt/RunEvent schema、nullable cancel request 与 Attempt deadline 字段及恢复索引、CancellationDispatch 状态/version/lease/backoff schema、Repository port、临时 Sequelize adapter、统一事件大小/分页上限、跨 adapter RunRepository contract suite(原子事务、回滚、Run/Attempt/RetryPolicy CAS、唯一错误、分页与取消恢复);ADR-0041 的 `pg-0003-run-retry-policy`、capability v2、driver-neutral PostgreSQL Run Repository 与真实 `pg.Pool` 上的共享 Repository/rollback/SQLSTATE contract;ADR-0063/0069/0071/0073/0074/0076 的独立 Node 24 local-sqlite typed schema、十二条 reviewed migration、capability v6、共享 operation authority、readiness/RunRepository/API credential repository/receipt journal/dispatch plan/encrypted Secret envelope/Project Policy/security audit/authorized mutation/stable Identity catalog、Drizzle↔真实 catalog table/column/index/CHECK/FK lockstep、base/adopted/application edge/standalone 产物门禁;ADR-0064 的 legacy baseline/plan digest、Online Backup recovery、side-by-side target migration、staged manifest、双库栅栏 activation、source 生命周期写栅栏、target stable identity 和重启语义;ADR-0065 的独立 cutover authority、外部副作用停机 evidence、append-only journal、start/restart/stop barrier 与 unknown→manual_required 收敛;ADR-0066 的 adopted storage→Run reconciliation→receipt maintenance→domain recovery→lifecycle→admission application gate、严格有界 recovery summary 与 admission-first reverse stop;ADR-0067 的 SQLite 事实驱动 Run 候选源、256 条硬上限、截断失败关闭和唯一 Repository authority;ADR-0068 的 receipt-first Reconciler、callback token/sequence fence、exact local-process identity、Attempt/Run/双 Event 原子终态推进和最终 verifier;ADR-0069 的 local-process 单向包边界、pre-spawn journal、受审 POSIX launcher、immutable receipt、exact identity 和 Profile-aware cleanup lifecycle;ADR-0070 的独立 local-execution、spawn 前后双 transaction CAS、callback digest、exact stop 补偿与 fail-closed starting 保留;ADR-0071 的独立 local-dispatch、不可变 revision/context、Secret-first materializer、Profile Artifact admission、4/64 MiB output hard quota 和窄 application facade;ADR-0073/0074 的 Project-bound SecretRef、AES-256-GCM、外置 keyring 生命周期、双 SQLite authority CAS、application preflight、强 Principal/Policy 和 envelope+audit 原子提交;ADR-0086 的本机 Owner provisioning/challenge/claim/delivery acknowledgement/credential recovery CLI;ADR-0377 的 Local/Cluster 同构、Profile-aware、Project-scoped Artifact range read | fresh database/pepper setup、credential rotation/GC 运维编排与 Secret/Project/Role/Approval 管理 CLI/API/UI、备份/rekey、2.x/target process controller、人工 recovery、target 写后 reconciliation 与完整 cutover/rollback 演练;retry 产品策略、Artifact retention/tombstone stack、具体本机 lifecycle 和 target executable;Linux x64/arm64、PID namespace、断电与固定路由设备门禁;PostgreSQL 16/18 双连接并发与 failover integration;Task revision/context 跨方言 contract/并发压力与引用感知 retention、Keyv 数据迁移 |
|
||||
| PR-2 Run 状态机 | Incubating | 纯转换表、终态/时间/错误/执行器元数据规则、Run version 与 event sequence CAS、事务性 RunCommandService、回滚测试 | 重复 Worker callback/fencing、并发数据库压力测试、Primary 执行链接入 |
|
||||
| PR-3 Executor 端口 | Incubating | ADR-0003、ExecutionSpec/Context/Handle/Result、Executor port、LocalProcessExecutor、进程组取消/超时升级、流式背压、Legacy Cron spec builder、真实进程 contract tests、可复现 edge 基准入口 | 固定 edge/多架构设备基线、Legacy builder 与 makeCommand 差异审计、Primary 生产流量接入 |
|
||||
| PR-4 Shadow Run | Incubating | origin 三态策略;默认关闭的 `QL3_SHADOW_ORIGINS`;manual、scheduled_node、boot、subscription、system 与 script 现有 ChildProcess 旁路观察;system crond 显式 origin marker、Shell execution ID、finish-only 准入、确定性 Run/Attempt 与 exact replay;`@once` 保持 manual、gRPC transport 不冒充 origin 的准入裁决;每个 worker 懒加载;Run/Attempt/Event 影子生命周期;稳定且不复制 caller 原文的 task identity/revision 与有界日志引用;同 worker 有界注册表和跨 worker 持久化候选关联;stop all/stop instance、Shell callback、乱序/迟到/歧义处理;监听前一次性、Profile-aware 的 keyset Startup Reconciler,终态证据补齐、lost/abandoned/pending 分流与 terminal Attempt response-loss 修复;origin-bounded 且逐级守恒的版本化 startup difference report、固定字段 metric batch 与一次性 collector;显式、只读、闭合窗口且 Profile-bounded 的 Shadow→Legacy 终态差异审计;128/256 MiB Linux arm64 资源门、SQLite 零增长与 Shadow enabled→off 进程重启回滚;失败开放和契约测试 | Legacy→Shadow capture authority、具体 exporter 和 Primary 门禁 |
|
||||
| PR-4 Shadow Run | Incubating | origin 三态策略;默认关闭的 `QL3_SHADOW_ORIGINS`;manual、scheduled_node、boot、subscription、system 与 script 现有 ChildProcess 旁路观察;system crond 显式 origin marker、Shell execution ID、finish-only 准入、确定性 Run/Attempt 与 exact replay;`@once` 保持 manual、gRPC transport 不冒充 origin 的准入裁决;每个 worker 懒加载;Run/Attempt/Event 影子生命周期;稳定且不复制 caller 原文的 task identity/revision 与有界日志引用;同 worker 有界注册表和跨 worker 持久化候选关联;stop all/stop instance、Shell callback、乱序/迟到/歧义处理;监听前一次性、Profile-aware 的 keyset Startup Reconciler,终态证据补齐、lost/abandoned/pending 分流与 terminal Attempt response-loss 修复;origin-bounded 且逐级守恒的版本化 startup difference report、固定字段 metric batch 与一次性 collector;显式、只读、闭合窗口且 Profile-bounded 的 Shadow→Legacy 终态差异审计;128/256 MiB Linux arm64 资源门、SQLite 零增长与 Shadow enabled→off 进程重启回滚;process-epoch Legacy admission/capture/failure/pending 守恒;clean-shutdown `0600` no-replace capture+startup exporter;manual Edge 8/Standalone 32–128 canary;capture/terminal/resource 自包含 Primary bundle;rollout v2 loader 重算 source digest 与 eligibility;失败开放和契约测试 | 首次真实目标实例 manual canary bundle、其他 origin 独立 capture/Primary gate、固定物理 edge/flash/断电证据 |
|
||||
| PR-5 Primary LocalExecutor | Incubating(默认不激活,仅 manifest-gated manual) | runtime-owned Run 创建器;持久化先于 spawn;Run/Attempt 完整成功、失败、取消、超时与 lost 闭环;Executor handle 身份校验;spawn 后激活写失败的 stop+lost 补偿;completion rejection 安全收敛;独立 Primary 幂等查询与唯一索引竞态裁决;durable `run.cancel_requested`、stop-before-signal、首次请求幂等、晚到完成裁决与待取消有界恢复查询;最多 64 条一页的 cross-worker cancellation source;独立 CancellationDispatch Repository 原子 claim/result、lease expiry 接管、owner/token/version fencing、指数退避与结果 RunEvent;最多 64 页的单周期 cancel supervisor;显式 start/stop、无重叠、错误隔离、停止等待有上限且 timer unref 的 lifecycle runner;Linux durable handle 的 PID/boot/start ticks/process-group 复验与 TERM/KILL controller;完整有界分页且 fail-closed 的 startup Reconcile supervisor;RunningInstance nullable `run_id/attempt_id` 关联;Primary 专用组合 Repository 在同一 SQLite 事务提交前投影 Crontab/RunningInstance,失败整体回滚;有界且防穿越的 legacy log output ref;manual owner seam、真实本机装配、单 spawn/fail-closed;严格 manual-only rollout manifest loader、短期审批/gate、配置哈希审计;HTTP worker 已接轻量 lazy bootstrap,accepted 后按 receipt-first reconcile→completion receipt lifecycle→timeout intent lifecycle→cancel dispatch lifecycle→router 顺序激活,失败撤销,监听失败和 shutdown 有界停止;Primary timeout 在 spawn 前持久化绝对 deadline,有界 source/requester/supervisor 只提交 timeout 意图并复用 CancellationDispatch;代码级 edge/standalone Profile 为各 lifecycle 提供不同 cadence 与页上限,cluster-control/worker 拒绝误装本机 SQLite Primary;统一 CompletionService 原子提交 Attempt/Run/双 Event,spawn 前保存 callback token hash、终态推进 sequence,实时回调与 receipt consumer 共享入口并覆盖两个清理 crash window;manual Primary 已接入受限 POSIX launcher、`0600` direct-file stdout/stderr、父进程退出后续写、不可覆盖 receipt 生产、回执环境清除、TERM 转发等待及 live transaction 后清理;Startup Reconciler receipt-first 双检查并在确定 exited 后执行 profile 化的单次 50/100 ms publish grace;`0007` 独立 CompletionReceiptJournal 在 spawn 前登记、为升级前 active Attempt 补登记并驱动周期扫描,使终态残留继续可发现;确定无效的已知 Attempt receipt 先持久化隔离状态,再进入确定性私有分片 quarantine;终态 missing 与 quarantine 按 edge/standalone retention 有界清理;非 Journal 文件具备只读优先、固定分片/条目上限、overflow fail-closed、显式同盘隔离的 Node 24 运维 CLI;扫描具备页上限、resume cursor、timer unref、无重叠、有界 stop 和低敏计数;ENOSPC 与 launcher receipt 存储失败有代码门禁 | 部署配置写入/审批入口与用户可见状态;PostgreSQL CancellationDispatch adapter;cluster-control 生产启动拓扑;固定 edge/Linux 多架构与真实磁盘压力基线、完整 2.x API 契约和回滚演练 |
|
||||
| PR-7 Worker Session、Run Lease 与启动协议基础 | Incubating(默认关闭,独立入口显式 opt-in) | ADR-0012/0013/0014/0021/0057–0061/0108–0121/0231–0239/0377;有界 capability/Placement/Dispatcher;SQLite 协议孵化与 PostgreSQL v9 Session/Run Lease/credential/attestation authority;immutable revision Placement、数据库时钟 keyset candidate、认证 Worker Pull、digest-only offer recovery;versioned capability-free ExecutionSpec response、stable claim 跨重启退避、单 owner 原子 inbox 准入与 TLS 1.3 mTLS/`ql3w` HTTPS client;同一 package journal 上 revision-fenced starting/spawn/started/running/completion 状态、callback digest、tagged no-spawn 与 ambiguous recovery;PostgreSQL starting/running/start-failure/completion 数据库权威事务、精确重放与 cancellation/timeout 优先终态;batch Secret delivery 在 Attempt advisory lock 下复验 Session/Lease/revision 完整围栏并复用单 Agent,Secret-before-Artifact materializer 将同一 log ID 交给 Executor/journal/running ACK;offer-scoped `wlog-*` 私有文件 spool、Edge/Node 容量策略、append/quota/path 防护、barrier 后 output ownership、受审 POSIX Executor、truncation fact、固定内存流式 source、认证 Artifact stream、共享 immutable store port、S3-compatible SSE/checksum/条件 promotion adapter、upload-before-completion 协调,以及 Local/Cluster 同构、Profile-aware、ETag-fenced range read;用户取消 run.stop mutation 以数据库时间写 intent/Event 并在事务内复验 Project/RoleBinding fence;非执行取消 convergence lifecycle、运行期 expiry 与安全 lost retry 已接入 cluster-control 单一全局 cadence;完整 generation/version/token/Attempt fencing;独立最小权限 Worker ingress、CA/CRL 与连接 generation 热重载;offer journal、spawn barrier、receipt-first recovery;独立 `@qinglong/worker-runtime` 的本地 P-256 CSR、key/chain/trust 验证、generation + active pointer 安装和持久退避;默认关闭的 production process 已装配具体 execution graph、完整 Session heartbeat/drain/offline、direct-file bootstrap、单 Agent/单 cadence、startup reconciliation、证书 maintenance、transport fail-close/recovery 与 Edge/Node 有界预算;真实 PostgreSQL 18 + Linux Node 合约已覆盖 Run completion、credential 和 CA 双轮换且保持同一 Session;真实 K3s 合约已覆盖 TLS/credential Secret 分权、双对象 CAS、Recreate 顺序、identity generation 与单节点 PVC recovery;所有能力默认不可达且受 edge/cluster import audit 约束 | 具体 cert-manager/Vault/SPIFFE/离线 CA adapter 与模板、ingress reload controller、生产 RBAC、证书到期告警和 `ql3w` credential recovery 产品面;具体 KMS/Vault Secret provider、对象存储 credential/temporary lifecycle 与 retention/tombstone;Worker 管理 API;真实 Kubernetes 多节点 CSI/node-loss/production 360 秒 drain 与固定 edge 文件系统 suspend/时钟/断电、x64/arm64 资源门禁 |
|
||||
| PR-8 Project/Policy/Approval Core | Incubating(默认拒绝、无生产业务执行入口) | ADR-0028;统一六类 ActorRef 与 exact-shape 校验;`0017` ownerless default Project 和 append-only versioned RoleBinding;owner/admin/operator/viewer 固定矩阵;Project 内 mutation 幂等、expected-version CAS、双 SQLite 连接竞争门禁;archived read-only、revocation、存储损坏 fail-closed;Agent 写/Secret/Tool `require_approval`;ADR-0047 把六类 subject、role/permission matrix 与 fence 抽到 runtime-core,`pg-0004-project-policy`/capability v3 建立 ownerless PostgreSQL baseline、严格 role/state CHECK、append-only runtime 权限、SERIALIZABLE Project lock、mutation replay、双连接单 winner 和 cluster admission authorizer;ADR-0049/`pg-0005` capability v4 建立 stable IdentitySubject、append-only digest-only API credential、真实 cluster bearer authenticator、write-only durable security audit 与最小权限 runtime role,且已验证 HTTP→credential→Policy→audit→handler 纵向链路;ADR-0051 建立 `/api/v3` 认证前 peer/global 双预算、transport-peer-only、无 timer 且有界内存的 overload shield;ADR-0027 Artifact authorizer adapter;ADR-0029 `AuthenticatedPrincipal` contract、`0018` digest-only versioned challenge、CSPRNG/TTL、同事务消费 challenge + 写首 owner、精确重放与双连接竞争/崩溃回滚门禁;ADR-0030 `0019` stable identity/binding、legacy HS384 + current-session membership、logout/platform/revoke/disable、single-factor 与损坏 fail-closed 门禁;ADR-0031 `0020` digest-bound ApprovalRequest、User-only decision、Project/Role version fence、精确 expiry/重放/并发裁决及同事务 immutable dispatch;ADR-0032 `0021` execution backfill、三表原子 consume、稳定 due keyset、claim/renew/start/result fencing、pre-start takeover/post-start recovery-required、attempt budget、handler inspect/digest barrier 和 bounded dispatcher;ADR-0033/`0022` control/resolution backfill、start/renew/completion 原子联动、稳定 recovery keyset、双 resolver claim/takeover、finding/result 精确重放、自动/人工终结、迟到 completion 单 winner 和 evidence-only bounded reconciler;ADR-0034/`0023` 首个 `run.create` canonical plan、Run/Attempt/Event/receipt 同事务、幂等 collision fail-closed、renew/终态 fence、真实 SQLite handler 与 automatic evidence provider;ADR-0035/`0024` 独立 `approval.recover` 矩阵、稳定 User + 五分钟强认证、Project/RoleBinding fence、human resolution + authorization fact 原子提交、撤权竞态与回滚门禁;ADR-0036 recovery-first 单 timer lifecycle、edge/standalone 独立 cadence/页预算、跨周期 cursor、非重叠与有界 stop;ADR-0074 以新的 Node 24 SQLite v5 ownerless Project/RoleBinding/audit authority 和独立 local-secret-admin 提供强 Principal、`secret.manage`、撤权 fence、envelope+allowed audit 原子提交及不回显语义;ADR-0086 以可信 POSIX console 和 staged delivery 完成本机首 Owner 产品 ceremony | fresh database/pepper setup 与安全迁移向导;`shareStore`/Express 到 authentication core 的 production migration;credential rotation/revocation API、mTLS/Worker enrollment、恢复码;Project/Role/Approval/Secret 管理 CLI/API/UI、audit retention/query/export/alert、preview Artifact/digest/immutable plan builder、真实 MFA/hardware adapter、人工 recovery API/UI/独立 rate limit 与审计事件、handler/provider registry、lifecycle startup/shutdown/指标/admission gate;PostgreSQL action/receipt/provider/recovery-authorization 与 OPA adapter、缓存 version 失效;Tool/Package/Secret/Shell 各自的 handler/evidence contract;Secret/Run/Tool/Workflow waiting_approval 全入口装配;完整回滚演练 |
|
||||
|
||||
@@ -239,7 +239,7 @@ Shadow Adapter 不得:
|
||||
|
||||
Shadow 转换仍必须遵守 ADR-0001。无法合法映射时追加 compat.transition_mismatch,不能强行覆盖终态。
|
||||
|
||||
当前 Alpha 切片对已审 Node worker origin 直接观察同一 ChildProcess 的 spawn、error 和 exit 事件,因此不依赖 Shell callback 才能形成基本终态。下述两级关联已补充 Shell callback、stop/cancel 和乱序/迟到回调;ADR-0448 又补充了监听前一次性启动恢复,ADR-0449 将其投影为 origin-bounded、版本化的差异报告与固定字段 metric batch。ADR-0450 再提供显式、只读、Profile-bounded 的闭合窗口终态审计;ADR-0451 已在 128 MiB router stress 与 256 MiB Edge release cgroup 中证明有界查询、SQLite 零增长和进程重启后的 Shadow-off 回滚。但 2.x RunningInstance 缺少可信 origin,因此这些证据只覆盖已写 Shadow Run 到 Legacy evidence 的一致性,不能单独证明 Legacy→Shadow 捕获率或替代正式 Primary gate。
|
||||
当前 Alpha 切片对已审 Node worker origin 直接观察同一 ChildProcess 的 spawn、error 和 exit 事件,因此不依赖 Shell callback 才能形成基本终态。下述两级关联已补充 Shell callback、stop/cancel 和乱序/迟到回调;ADR-0448 又补充了监听前一次性启动恢复,ADR-0449 将其投影为 origin-bounded、版本化的差异报告与固定字段 metric batch。ADR-0450 再提供显式、只读、Profile-bounded 的闭合窗口终态审计;ADR-0451 已在 128 MiB router stress 与 256 MiB Edge release cgroup 中证明有界查询、SQLite 零增长和进程重启后的 Shadow-off 回滚。ADR-0453 不再从 2.x RunningInstance 猜测反向分母,而是在默认 Legacy bridge admission 建立 process-epoch 守恒 token,并把 capture/startup、terminal 与 resource/rollback 三类低敏 source report 打包;rollout v2 loader 会独立重算 bundle 后才允许 manual Primary。
|
||||
|
||||
### 9.4 `next` Alpha callback 与 stop 关联
|
||||
|
||||
@@ -253,7 +253,7 @@ Shadow 转换仍必须遵守 ADR-0001。无法合法映射时追加 compat.trans
|
||||
6. 取消事实在 Legacy kill 前投递;同 worker 的后续 exit 排在取消之后。跨 worker 使用持久化定位器尽力关联,任何查询或写入失败都不能阻断 kill 或改变 2.x API 响应。
|
||||
7. 乱序 finished 可以从 queued/claimed 补齐 dispatching、starting、running 和终态;重复终态 callback、取消后的迟到成功 callback 不覆盖终态,也不追加重复完成事件。
|
||||
|
||||
这仍不是完整的 Shadow→Primary 门禁:ADR-0448 已提供启动后有界批量扫描、终态证据补齐、lost/abandoned 收敛和两事务 response-loss 修复;ADR-0449 已增加 startup 差异报表、固定低基数 metric batch 与可注入单次 collector;ADR-0450 已完成不伪造反向捕获率的闭合窗口 Shadow→Legacy 终态差异查询;ADR-0451 已完成 Profile-aware 资源压力和 Shadow-off 进程重启回滚。具体 exporter、Legacy→Shadow capture authority 和正式 Primary gate 尚未完成。后续能力不得让 edge 增加常驻 watcher 或无界内存队列。
|
||||
ADR-0453 已补齐 manual origin 的正式 Shadow→Primary 判定契约:process-local admission/capture/failure/pending 守恒、clean-shutdown 一次性 exporter、capture/startup + terminal + resource/rollback 自包含 bundle,以及 rollout v2 loader 的独立重算。它仍不会自动启用 Primary;目标实例必须产生真实 manual canary bundle,其他 origin 也必须独立评审。后续能力不得让 edge 增加常驻 watcher 或无界内存队列。
|
||||
|
||||
### 9.3 Shadow 写失败
|
||||
|
||||
|
||||
+2
-1
@@ -73,5 +73,6 @@ ADR-0450 已冻结闭合窗口的 Shadow→Legacy 终态审计语义,但只证
|
||||
|
||||
## 后续
|
||||
|
||||
D-360 仍须建立可信的 origin-scoped Legacy→Shadow capture authority,组合 observer failure/capture evidence 与本 ADR/ADR-0450 的结果,再定义正式 Primary gate。
|
||||
D-360/ADR-0453 已建立 origin-scoped Legacy→Shadow capture authority,并把 observer capture/failure、startup、ADR-0450 terminal 与本 ADR resource/rollback
|
||||
组合为可由 rollout v2 loader 独立重放的 manual Primary gate bundle;首次目标实例 manual canary bundle 仍必须在实际启用前产生。
|
||||
固定物理 edge、真实 flash 写放大和断电演练必须作为独立现场证据,不能由本 ADR 的 Docker arm64 结果替代。
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# ADR-0453:Origin-scoped Legacy Shadow 捕获权威与 Primary 门禁
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-19
|
||||
- 关联 RFC:QL-RFC-0001 D-02、D-360、PR-4、PR-5
|
||||
- 关联 ADR:ADR-0002、ADR-0449、ADR-0450、ADR-0451
|
||||
- Amends:ADR-0002 的 Shadow→Primary 门禁、ADR-0451 的 Legacy→Shadow capture 缺口
|
||||
|
||||
## 上下文
|
||||
|
||||
ADR-0450 能证明已存在的 legacy-owned Shadow Run 与 Legacy 终态一致,但它明确不测量没有 Shadow Run 的 Legacy execution。ADR-0451 又证明
|
||||
资源预算和 enabled→off 回滚,却仍不能把“看到的 Shadow 都正确”推导成“每个 Legacy admission 都被捕获”。旧
|
||||
`shadowBridgeFailureSnapshot()` 只有进程累计失败 key,没有 admission 分母、pending 守恒、测量窗口或可供 rollout loader 重放的权威文件。
|
||||
|
||||
Primary manifest v1 还只要求维护者填写五个 `"passed"`。即使审批和文件摘要有效,运行时也无法重算这些字符串背后的 capture、terminal agreement、
|
||||
startup convergence 与 rollback evidence;把字符串替换为一份只声明 `eligible` 的 receipt 同样不构成门禁。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 已配置 origin 进入默认 Legacy Shadow bridge 后、构造 fact 前,必须取得一次 process-local admission token。token 只能结算为
|
||||
`captured | failed | pending`;`captured + failed + pending = admitted`,failure 进一步固定为 `fact | observer | initialization | accept` 四类并守恒。
|
||||
Legacy spawn、返回值和失败开放语义不变;测试 override 不构成生产 capture authority。
|
||||
2. `LegacyShadowRunObserver` 暴露只读异步 `captureSettled()`:只有真实 `LegacyShadowRunWriter.accept()` 成功才结算 captured。初始化失败、fact 构造失败、
|
||||
observer begin 失败或 accept 失败分别结算到固定失败类;后续 spawned/running/terminal 写入仍由 ADR-0450 终态审计裁决,不能用 accept 成功替代终态一致。
|
||||
3. snapshot schema 为 `qinglong/legacy-shadow-capture-snapshot@v1`,用随机 process epoch、时间和最多七行 origin 固定计数表达,不含 PID、Run、Attempt、Cron、
|
||||
task、command、path、用户或错误消息。window report schema 为 `qinglong/legacy-shadow-capture-report@v1`;起始 snapshot 必须零 pending,同 epoch 前后差分、
|
||||
origin exact coverage 和计数单调必须成立。
|
||||
4. HTTP worker 在 Legacy normalization 和 ADR-0449 startup reconciliation 之后、Primary bootstrap/listen 之前装配一次性 exporter。只有显式设置
|
||||
`QL3_SHADOW_CAPTURE_EVIDENCE_FILE=<basename>.json` 才开启;文件固定写入既有 config root,basename 禁止路径、`.` 前缀和 `..`。exporter 无 timer、watcher、
|
||||
listener、重试队列或数据库连接,只在干净 shutdown 写一次 `0600`、`wx` no-replace JSON;失败只形成低敏审计,不改变 Legacy shutdown。
|
||||
5. capture evidence schema 为 `qinglong/legacy-shadow-capture-evidence@v1`,同时嵌入同进程启动时的 ADR-0449 report。只有 startup `converged`、Profile 与
|
||||
origin 顺序精确一致、capture assessment 为 `captured` 才 qualified;crash、非干净退出、partial/no-replace 写失败、empty、pending 或任一失败都不能产生
|
||||
Primary eligibility。
|
||||
6. `gate:legacy-shadow-primary:ql3` 组合三份低敏输入:capture/startup evidence、ADR-0450 closed terminal report、ADR-0451 compiled-backend full rollback/resource
|
||||
report。当前只允许 `manual`:edge 必须精确 8 个 admission;standalone 为 32–128 个。terminal window 必须与 capture window 逐值相等,scanned/matched 必须
|
||||
等于 captured,closed、evidence complete、无 remaining,terminal agreement 与 fully comparable 都必须为 1000/1000。资源报告必须同 Profile、full、
|
||||
compiled backend、qualified,并证明 Legacy continued、Shadow stopped 与 SQLite integrity `ok`。
|
||||
7. Primary gate bundle schema 为 `qinglong/legacy-shadow-primary-gate@v1`。它嵌入三份 source report、各自 canonical JSON SHA-256、固定计数、window、结论与固定
|
||||
violation code;不嵌入原文件路径。CLI 用 `O_NOFOLLOW`、1 MiB 上限读取输入,并以 `0600`、no-replace 写 bundle;ineligible 时不写输出。
|
||||
8. Rollout manifest 升为 schema v2。enabled manifest 必须增加 `primaryGate` reference,只允许 `manual`、同 config 目录 basename 和 64-hex bundle digest。
|
||||
loader 以 no-follow、64 KiB 上限读取 bundle,验证 manifest digest,重新计算 embedded source canonical digests,并从 source reports 重新执行完整 Primary gate;
|
||||
它不信任 CLI 写入的 `assessment`。bundle 必须 eligible、生成时间不晚于审批时间且 Profile 与实际 Local deployment 相同,之后才可惰性加载 Primary stack。
|
||||
9. v1 enabled manifest 失败关闭,不自动补写或猜测 gate。disabled/missing/rejected 仍保持零 Primary stack、router、timer 和连接。`defaultMode=off`、manual-only、
|
||||
最长 30 天审批、rollback plan 和既有 durable cancellation/atomic projection gates 保留。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
### 给 RunningInstances 增加 origin 后直接当分母
|
||||
|
||||
拒绝。2.x 多条 Node/Shell 路径只在 spawn/callback 后写 RunningInstances,缺失行本身不可见,无法证明 admission capture;为兼容观测改成数据库
|
||||
fail-closed 还会改变 Legacy 可用性。
|
||||
|
||||
### 每次 Legacy execution 同步写一条新 admission ledger
|
||||
|
||||
拒绝作为本阶段方案。它会让 Shadow 数据库写参与 Legacy spawn 前置路径,并在低配 flash 上形成第二份逐执行持久化权威。若未来需要跨 crash 的在线连续窗口,必须以
|
||||
独立 migration、retention、写放大和故障语义重新评审,不能暗中加入兼容桥。
|
||||
|
||||
### 只在日志里输出累计 counter
|
||||
|
||||
拒绝。日志片段没有同 epoch 起止、pending baseline、startup report binding、no-replace 文件或 loader 重放;丢日志时也不能 fail-closed。
|
||||
|
||||
### Loader 只验证 `eligible` receipt 的摘要
|
||||
|
||||
拒绝。摘要只能证明文件没变,不能证明内容真实执行 gate。v2 bundle 必须携带低敏 source reports,loader 必须独立重算 digest 和结论。
|
||||
|
||||
## 资源、安全与部署影响
|
||||
|
||||
- 每个已启用 Legacy admission 增加一个常数 token 和四个固定 counter 更新;最多七行 origin,没有按执行身份保存集合,不增加 timer、watcher、listener、线程、连接或
|
||||
schema。未配置 Shadow origins 时仍在 fact factory、observer/Repository import 和 capture authority admission 前返回。
|
||||
- 正常运行不写 capture 文件;只有显式证据 canary 的干净 shutdown 写一个有界文件。edge/standalone runtime 不需要 Prometheus、OTel、外部数据库、对象存储或
|
||||
Cluster 组件。
|
||||
- bundle 是本机 config-root trust domain 内的 rollout evidence,不是签名供应链 attestation。拥有 config root 写权限的 operator 仍是本机信任根;公开分发或跨主机
|
||||
delegation 需另加签名 ceremony。
|
||||
- 本 Gate 只开放 manual eligibility 的判定能力,不自动写 manifest、不启用 Primary、不接管 scheduled/system/boot/gRPC origin,也不证明物理 flash、断电或生产任务内容。
|
||||
|
||||
## 验证
|
||||
|
||||
- 纯 authority 覆盖成功、四类失败、pending、跨 epoch、非零 pending baseline、origin coverage 和脱敏守恒。
|
||||
- exporter 覆盖 armed→exported、clean shutdown、`0600`、no-replace、重复 close、缺失 startup、未配置和失败开放。
|
||||
- gate/CLI 覆盖 exact edge cohort、样本不足、terminal 漂移、audit-only rollback、symlink、no-replace、embedded source 篡改和 canonical digest 重算。
|
||||
- rollout v2 覆盖 missing/tampered/ineligible bundle、审批时间、Profile mismatch、unknown field/path traversal 和 disabled lazy path。
|
||||
- 真实 compiled backend 的 `ScheduleService.runTask` enabled child 已通过默认 observer/Repository 产生一个 `system` capture:admitted/captured 为 1/1,failed/pending 为
|
||||
0/0;它只证明 bridge 真实结算链,不冒充 manual Primary 的 8/32 条正式 canary。
|
||||
- 阶段门已从 clean package artifacts 重跑:D-360 聚焦测试 `48/48`、Legacy/Shadow 串行扩展 `117/117`、资源/回滚专项 `4/4`、`build:back`、
|
||||
完整 backend `1,469 pass / 0 fail / 2 conditional skip`、18-package clean build/test、四项可执行架构审计与 `14/14` Local Profile artifact audit
|
||||
全部通过。产物字节保持 D-358 基线:base `2,589,998 / 2,590,076`、adopted `2,809,293 / 2,809,416`、application
|
||||
`3,632,877 / 3,632,997`、application-api `3,800,430 / 3,800,574`、AI `3,069,251 / 3,069,341`、application+AI
|
||||
`4,493,151 / 4,493,283`、MCP `7,315,930 / 7,316,038`。
|
||||
- Linux arm64 Docker 资源门再次通过:router stress 保持 `128 MiB / 0.5 CPU / 0 swap / 64 PID`,cgroup peak `95,113,216` bytes;Edge release
|
||||
保持 `256 MiB / 1 CPU / 0 swap / 128 PID`,13 个 workload 全部通过,cgroup peak `144,740,352` bytes,`memory.events` 的
|
||||
`max/oom/oom_kill` 增量均为 0。Edge full rollback 中默认 `system` bridge 的真实 capture 为 `1/1`,terminal audit p95 `5.236 ms`、RSS delta
|
||||
`2,621,440` bytes、数据库存储前后稳定。Docker arm64 仍不是物理路由、flash wear 或断电证据。
|
||||
- D-360 未修改 PostgreSQL schema、migration、依赖树或 Kubernetes 拓扑,因此不重跑 PostgreSQL HA;相邻 D-359 的 PostgreSQL HA `142/142` 与
|
||||
timeline `1→2` 只作为未被本阶段触碰的既有证据,不冒充本阶段新结果。
|
||||
|
||||
## 后续
|
||||
|
||||
正式启用 manual Primary 前,维护者必须在目标 edge/standalone 实例完成一次真实、干净关闭的 manual canary,等待 settling 后运行 terminal audit,组合与同版本
|
||||
resource report 生成 bundle,再由 v2 loader 重放。固定物理路由、flash 写放大、断电、非干净退出和 config-root 签名/备份仍是独立发布证据;其他 origin 必须分别建立
|
||||
自己的 admission authority、样本预算和 rollback gate,不能复用 manual receipt。
|
||||
@@ -456,6 +456,7 @@
|
||||
| [ADR-0450](./ADR-0450-closed-window-legacy-shadow-terminal-difference-audit.md) | 闭合窗口的 Legacy Shadow 终态差异审计 | Accepted |
|
||||
| [ADR-0451](./ADR-0451-profile-bounded-legacy-shadow-resource-and-off-rollback-evidence.md) | 按 Profile 有界的 Legacy Shadow 资源与关闭回滚证据 | Accepted |
|
||||
| [ADR-0452](./ADR-0452-atomic-flattened-backend-build-publication.md) | 原子且扁平兼容的 Backend 构建发布 | Accepted |
|
||||
| [ADR-0453](./ADR-0453-origin-scoped-legacy-shadow-capture-authority-and-primary-gate.md) | Origin-scoped Legacy Shadow 捕获权威与 Primary 门禁 | Accepted(首次真实目标实例 manual canary 待执行) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"audit:legacy-schema:ql3": "node scripts/ql3-schema-audit.cjs",
|
||||
"audit:receipts:ql3": "node scripts/ql3-receipt-audit.cjs",
|
||||
"audit:legacy-shadow-terminal:ql3": "node scripts/ql3-legacy-shadow-terminal-audit.cjs",
|
||||
"gate:legacy-shadow-primary:ql3": "node scripts/ql3-legacy-shadow-primary-gate.cjs",
|
||||
"audit:edge-imports:ql3": "node scripts/ql3-edge-import-audit.cjs",
|
||||
"audit:cluster-dependencies:ql3": "node scripts/ql3-cluster-dependency-audit.cjs",
|
||||
"audit:package-boundaries:ql3": "node scripts/ql3-package-boundary-audit.cjs",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
createLegacyShadowPrimaryGateReceipt,
|
||||
} = require('../back/runtime/domain/legacyShadowPrimaryGate');
|
||||
|
||||
const MAX_EVIDENCE_BYTES = 1024 * 1024;
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = { profile: 'edge' };
|
||||
for (const argument of argv) {
|
||||
if (argument === '--') continue;
|
||||
if (argument.startsWith('--profile=')) {
|
||||
options.profile = argument.slice('--profile='.length);
|
||||
} else if (argument.startsWith('--capture=')) {
|
||||
options.capturePath = path.resolve(argument.slice('--capture='.length));
|
||||
} else if (argument.startsWith('--terminal=')) {
|
||||
options.terminalPath = path.resolve(argument.slice('--terminal='.length));
|
||||
} else if (argument.startsWith('--resource=')) {
|
||||
options.resourcePath = path.resolve(argument.slice('--resource='.length));
|
||||
} else if (argument.startsWith('--output=')) {
|
||||
options.outputPath = path.resolve(argument.slice('--output='.length));
|
||||
} else if (argument.startsWith('--generated-at-ms=')) {
|
||||
const raw = argument.slice('--generated-at-ms='.length);
|
||||
if (!/^\d+$/u.test(raw)) {
|
||||
throw new TypeError('--generated-at-ms must be an integer');
|
||||
}
|
||||
options.generatedAtMs = Number(raw);
|
||||
} else {
|
||||
throw new TypeError(`Unsupported argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
if (options.profile !== 'edge' && options.profile !== 'standalone') {
|
||||
throw new TypeError('--profile must be edge or standalone');
|
||||
}
|
||||
for (const name of [
|
||||
'capturePath',
|
||||
'terminalPath',
|
||||
'resourcePath',
|
||||
'outputPath',
|
||||
]) {
|
||||
if (!options[name] || !path.isAbsolute(options[name])) {
|
||||
throw new TypeError(`--${name.replace('Path', '')} is required`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.generatedAtMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.generatedAtMs) || options.generatedAtMs < 0)
|
||||
) {
|
||||
throw new TypeError('--generated-at-ms is invalid');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function readEvidence(sourcePath) {
|
||||
const descriptor = fs.openSync(
|
||||
sourcePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
(stat.mode & 0o077) !== 0 ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_EVIDENCE_BYTES
|
||||
) {
|
||||
throw new TypeError('Primary gate evidence file shape is invalid');
|
||||
}
|
||||
const bytes = Buffer.alloc(stat.size);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const count = fs.readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (count === 0) throw new Error('Primary gate evidence read stalled');
|
||||
offset += count;
|
||||
}
|
||||
return {
|
||||
value: JSON.parse(bytes.toString('utf8')),
|
||||
};
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function writeReceipt(outputPath, receipt) {
|
||||
const descriptor = fs.openSync(outputPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(receipt)}\n`, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function run(options) {
|
||||
const capture = readEvidence(options.capturePath);
|
||||
const terminal = readEvidence(options.terminalPath);
|
||||
const resource = readEvidence(options.resourcePath);
|
||||
const receipt = createLegacyShadowPrimaryGateReceipt({
|
||||
profile: options.profile,
|
||||
generatedAtMs: options.generatedAtMs ?? Date.now(),
|
||||
capture: capture.value,
|
||||
terminal: terminal.value,
|
||||
resource: resource.value,
|
||||
});
|
||||
if (receipt.assessment !== 'eligible') {
|
||||
const error = new Error(
|
||||
`Legacy Shadow Primary gate is ineligible: ${receipt.violations.join(
|
||||
',',
|
||||
)}`,
|
||||
);
|
||||
error.code = 'QL3_PRIMARY_GATE_INELIGIBLE';
|
||||
throw error;
|
||||
}
|
||||
writeReceipt(options.outputPath, receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const receipt = run(parseArguments(process.argv.slice(2)));
|
||||
process.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_EVIDENCE_BYTES,
|
||||
parseArguments,
|
||||
readEvidence,
|
||||
run,
|
||||
writeReceipt,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -369,7 +369,7 @@ async function runSummary(database) {
|
||||
});
|
||||
}
|
||||
|
||||
async function rollbackChild(mode, expectedBefore) {
|
||||
async function rollbackChild(mode, expectedBefore, profile) {
|
||||
const databasePath = process.env.QL3_SHADOW_DRILL_DATABASE;
|
||||
if (!path.isAbsolute(databasePath ?? '')) {
|
||||
throw new QingLong3LegacyShadowResourceRollbackError(
|
||||
@@ -380,10 +380,17 @@ async function rollbackChild(mode, expectedBefore) {
|
||||
await taskLimit.setCustomLimit();
|
||||
const ScheduleService = fromRuntime('services/schedule').default;
|
||||
const bridge = fromRuntime('runtime/compatibility/legacyExecutionBridge');
|
||||
const { createLegacyShadowCaptureReport } = fromRuntime(
|
||||
'runtime/application/legacyShadowCaptureAuthority',
|
||||
);
|
||||
const { sequelize } = fromRuntime('data');
|
||||
let shortCircuitFactCalls = 0;
|
||||
try {
|
||||
const configuredOrigins = bridge.configuredLegacyShadowOrigins();
|
||||
const captureBefore =
|
||||
mode === 'enabled'
|
||||
? bridge.legacyShadowCaptureSnapshot(['system'])
|
||||
: undefined;
|
||||
if (mode === 'off') {
|
||||
const observation = bridge.observeLegacyExecution('system', () => {
|
||||
shortCircuitFactCalls += 1;
|
||||
@@ -434,6 +441,15 @@ async function rollbackChild(mode, expectedBefore) {
|
||||
`${mode} restart did not preserve the expected Legacy result`,
|
||||
);
|
||||
}
|
||||
const capture =
|
||||
mode === 'enabled'
|
||||
? createLegacyShadowCaptureReport(
|
||||
profile,
|
||||
['system'],
|
||||
captureBefore,
|
||||
bridge.legacyShadowCaptureSnapshot(['system']),
|
||||
)
|
||||
: undefined;
|
||||
return Object.freeze({
|
||||
mode,
|
||||
configuredOrigins,
|
||||
@@ -445,6 +461,7 @@ async function rollbackChild(mode, expectedBefore) {
|
||||
shortCircuitFactCalls,
|
||||
defaultObserverLoaded,
|
||||
repositoryLoaded,
|
||||
...(capture === undefined ? {} : { capture }),
|
||||
peakProcessRssBytes: process.resourceUsage().maxRSS * 1024,
|
||||
});
|
||||
} finally {
|
||||
@@ -558,6 +575,7 @@ async function runEvidence(options) {
|
||||
[
|
||||
'--internal-child=rollback-enabled',
|
||||
`--expected-before=${fixtureRunCount}`,
|
||||
`--profile=${options.profile}`,
|
||||
],
|
||||
{ ...childEnvironment, QL3_SHADOW_ORIGINS: 'system' },
|
||||
)
|
||||
@@ -568,6 +586,7 @@ async function runEvidence(options) {
|
||||
[
|
||||
'--internal-child=rollback-off',
|
||||
`--expected-before=${fixtureRunCount + 1}`,
|
||||
`--profile=${options.profile}`,
|
||||
],
|
||||
{ ...childEnvironment, QL3_SHADOW_ORIGINS: '' },
|
||||
)
|
||||
@@ -653,6 +672,7 @@ async function runEvidence(options) {
|
||||
runDelta: enabled.runDelta,
|
||||
defaultObserverLoaded: enabled.defaultObserverLoaded,
|
||||
repositoryLoaded: enabled.repositoryLoaded,
|
||||
capture: enabled.capture,
|
||||
peakProcessRssBytes: enabled.peakProcessRssBytes,
|
||||
}),
|
||||
off: Object.freeze({
|
||||
@@ -729,6 +749,7 @@ async function main() {
|
||||
report = await rollbackChild(
|
||||
mode === 'rollback-enabled' ? 'enabled' : 'off',
|
||||
expectedBefore,
|
||||
profile,
|
||||
);
|
||||
} else {
|
||||
throw new QingLong3LegacyShadowResourceRollbackError(
|
||||
|
||||
@@ -25,6 +25,34 @@ function loadResult(status, mode = 'off') {
|
||||
sourcePath: '/data/config/qinglong3-rollout.json',
|
||||
status,
|
||||
},
|
||||
...(status === 'accepted'
|
||||
? {
|
||||
primaryGateReceipt: {
|
||||
schema: 'qinglong/legacy-shadow-primary-gate@v1',
|
||||
schemaVersion: 1,
|
||||
profile: 'standalone',
|
||||
origin: 'manual',
|
||||
generatedAtMs: NOW - 2_000,
|
||||
assessment: 'eligible',
|
||||
window: {
|
||||
startInclusiveMs: NOW - 10_000,
|
||||
endExclusiveMs: NOW - 5_000,
|
||||
},
|
||||
counts: {
|
||||
admitted: 32,
|
||||
captured: 32,
|
||||
terminalScanned: 32,
|
||||
terminalMatched: 32,
|
||||
},
|
||||
evidence: {
|
||||
captureSha256: 'a'.repeat(64),
|
||||
terminalSha256: 'b'.repeat(64),
|
||||
resourceSha256: 'c'.repeat(64),
|
||||
},
|
||||
violations: [],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,6 +199,27 @@ test('accepted bootstrap audits a lazy stack import failure without installing',
|
||||
assert.deepEqual(calls, ['load-stack', 'audit:failed']);
|
||||
});
|
||||
|
||||
test('accepted bootstrap rejects a Primary receipt for another Profile before loading', async () => {
|
||||
const calls = [];
|
||||
const load = loadResult('accepted', 'primary');
|
||||
load.primaryGateReceipt.profile = 'edge';
|
||||
await assert.rejects(
|
||||
bootstrapDefaultManualPrimaryRuntime({
|
||||
load: async () => load,
|
||||
deploymentProfile: 'standalone',
|
||||
async loadStack() {
|
||||
calls.push('load-stack');
|
||||
throw new Error('must remain lazy');
|
||||
},
|
||||
audit(record) {
|
||||
calls.push(`audit:${record.activation}`);
|
||||
},
|
||||
}),
|
||||
/does not authorize this deployment Profile/,
|
||||
);
|
||||
assert.deepEqual(calls, ['audit:failed']);
|
||||
});
|
||||
|
||||
test('disabled bootstrap stays inert even with an invalid deployment profile', async () => {
|
||||
const previous = process.env.QL_DEPLOYMENT_PROFILE;
|
||||
process.env.QL_DEPLOYMENT_PROFILE = 'invalid-profile';
|
||||
@@ -219,7 +268,7 @@ test('accepted bootstrap rejects and audits an invalid deployment profile', asyn
|
||||
}),
|
||||
/QL_DEPLOYMENT_PROFILE is invalid/,
|
||||
);
|
||||
assert.deepEqual(calls, ['load-stack', 'audit:selected', 'audit:failed']);
|
||||
assert.deepEqual(calls, ['audit:failed']);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.QL_DEPLOYMENT_PROFILE;
|
||||
else process.env.QL_DEPLOYMENT_PROFILE = previous;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { afterEach, test } = require('node:test');
|
||||
const {
|
||||
LegacyShadowCaptureAuthority,
|
||||
} = require('../../back/runtime/application/legacyShadowCaptureAuthority');
|
||||
const {
|
||||
bootstrapLegacyShadowCaptureEvidence,
|
||||
} = require('../../back/runtime/adapters/legacy/bootstrapLegacyShadowCaptureEvidence');
|
||||
|
||||
const directories = [];
|
||||
|
||||
function startup(state = 'reconciled') {
|
||||
if (state !== 'reconciled') return { state };
|
||||
return {
|
||||
state: 'reconciled',
|
||||
profile: 'edge',
|
||||
origins: 1,
|
||||
summary: {},
|
||||
metrics: {},
|
||||
report: {
|
||||
schema: 'qinglong/legacy-shadow-startup-difference-report@v1',
|
||||
schemaVersion: 1,
|
||||
profile: 'edge',
|
||||
assessment: 'converged',
|
||||
configuredOriginCount: 1,
|
||||
budget: { pageSize: 8, maxPages: 1, maxCandidates: 8 },
|
||||
coverage: {
|
||||
pages: 1,
|
||||
scanned: 0,
|
||||
stopReason: 'complete',
|
||||
remaining: false,
|
||||
resumeAvailable: false,
|
||||
},
|
||||
outcomes: {
|
||||
completed: 0,
|
||||
cancelled: 0,
|
||||
abandoned: 0,
|
||||
markedLost: 0,
|
||||
repaired: 0,
|
||||
pending: 0,
|
||||
ambiguous: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
byOrigin: [
|
||||
{
|
||||
origin: 'manual',
|
||||
scanned: 0,
|
||||
completed: 0,
|
||||
cancelled: 0,
|
||||
abandoned: 0,
|
||||
markedLost: 0,
|
||||
repaired: 0,
|
||||
pending: 0,
|
||||
ambiguous: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories
|
||||
.splice(0)
|
||||
.map((directory) => fs.rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test('exports one qualified no-identity capture report on clean shutdown', async () => {
|
||||
let now = 1_750_300_000_000;
|
||||
const authority = new LegacyShadowCaptureAuthority(
|
||||
{ now: () => now },
|
||||
'019f75d2-3333-7333-8333-333333333333',
|
||||
);
|
||||
let evidence;
|
||||
const audits = [];
|
||||
const handle = await bootstrapLegacyShadowCaptureEvidence({
|
||||
startup: startup(),
|
||||
origins: ['manual'],
|
||||
profile: 'edge',
|
||||
outputPath: '/private/evidence.json',
|
||||
snapshot: (origins) => authority.snapshot(origins),
|
||||
async write(_outputPath, value) {
|
||||
evidence = value;
|
||||
},
|
||||
audit: (record) => audits.push(record),
|
||||
});
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
authority.admit('manual').captured();
|
||||
}
|
||||
now += 1_000;
|
||||
|
||||
assert.equal(handle.active, true);
|
||||
assert.equal((await handle.close()).state, 'exported');
|
||||
assert.deepEqual(await handle.close(), audits.at(-1));
|
||||
assert.equal(evidence.qualification.passed, true);
|
||||
assert.equal(evidence.capture.totals.admitted, 8);
|
||||
assert.equal(evidence.capture.capturePermille, 1_000);
|
||||
assert.doesNotMatch(JSON.stringify(evidence), /taskId|runId|attemptId|pid/);
|
||||
assert.deepEqual(
|
||||
audits.map((record) => record.state),
|
||||
['armed', 'exported'],
|
||||
);
|
||||
});
|
||||
|
||||
test('writes owner-private evidence once and refuses overwrite', async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-capture-'));
|
||||
directories.push(directory);
|
||||
const outputPath = path.join(directory, 'capture.json');
|
||||
let now = 1_750_300_000_000;
|
||||
const authority = new LegacyShadowCaptureAuthority(
|
||||
{ now: () => now },
|
||||
'019f75d2-4444-7444-8444-444444444444',
|
||||
);
|
||||
const options = {
|
||||
startup: startup(),
|
||||
origins: ['manual'],
|
||||
profile: 'edge',
|
||||
outputPath,
|
||||
snapshot: (origins) => authority.snapshot(origins),
|
||||
audit() {},
|
||||
};
|
||||
const first = await bootstrapLegacyShadowCaptureEvidence(options);
|
||||
authority.admit('manual').captured();
|
||||
now += 1_000;
|
||||
assert.equal((await first.close()).state, 'exported');
|
||||
assert.equal((await fs.stat(outputPath)).mode & 0o777, 0o600);
|
||||
|
||||
const second = await bootstrapLegacyShadowCaptureEvidence(options);
|
||||
authority.admit('manual').captured();
|
||||
now += 1_000;
|
||||
assert.equal((await second.close()).state, 'failed');
|
||||
});
|
||||
|
||||
test('stays inert without an explicit path and rejects missing startup authority', async () => {
|
||||
const disabled = await bootstrapLegacyShadowCaptureEvidence({
|
||||
startup: { state: 'disabled' },
|
||||
origins: [],
|
||||
profile: 'edge',
|
||||
audit() {},
|
||||
});
|
||||
assert.equal(disabled.active, false);
|
||||
assert.equal((await disabled.close()).state, 'disabled');
|
||||
|
||||
const failed = await bootstrapLegacyShadowCaptureEvidence({
|
||||
startup: { state: 'incomplete' },
|
||||
origins: ['manual'],
|
||||
profile: 'edge',
|
||||
outputPath: '/private/evidence.json',
|
||||
audit() {},
|
||||
});
|
||||
assert.equal(failed.active, false);
|
||||
assert.equal((await failed.close()).state, 'failed');
|
||||
});
|
||||
@@ -319,6 +319,9 @@ test('HTTP startup orders Shadow recovery after Legacy normalization and before
|
||||
const shadowRecovery = source.indexOf(
|
||||
'await bootstrapLegacyShadowStartupReconciliation()',
|
||||
);
|
||||
const captureEvidence = source.indexOf(
|
||||
'await bootstrapLegacyShadowCaptureEvidence({',
|
||||
);
|
||||
const primaryActivation = source.indexOf(
|
||||
'await bootstrapDefaultManualPrimaryRuntime()',
|
||||
);
|
||||
@@ -328,6 +331,7 @@ test('HTTP startup orders Shadow recovery after Legacy normalization and before
|
||||
|
||||
assert.equal(legacyNormalization >= 0, true);
|
||||
assert.equal(legacyNormalization < shadowRecovery, true);
|
||||
assert.equal(shadowRecovery < primaryActivation, true);
|
||||
assert.equal(shadowRecovery < captureEvidence, true);
|
||||
assert.equal(captureEvidence < primaryActivation, true);
|
||||
assert.equal(primaryActivation < listen, true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
LegacyShadowCaptureAuthority,
|
||||
createLegacyShadowCaptureReport,
|
||||
} = require('../../back/runtime/application/legacyShadowCaptureAuthority');
|
||||
|
||||
const EPOCH = '019f75d2-1111-7111-8111-111111111111';
|
||||
|
||||
function fixture() {
|
||||
let now = 1_750_200_000_000;
|
||||
const authority = new LegacyShadowCaptureAuthority({ now: () => now }, EPOCH);
|
||||
return {
|
||||
authority,
|
||||
advance(milliseconds = 1_000) {
|
||||
now += milliseconds;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('creates a conserved origin-scoped capture window', () => {
|
||||
const value = fixture();
|
||||
const before = value.authority.snapshot(['manual']);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
value.authority.admit('manual').captured();
|
||||
}
|
||||
value.advance();
|
||||
const after = value.authority.snapshot(['manual']);
|
||||
|
||||
const report = createLegacyShadowCaptureReport(
|
||||
'edge',
|
||||
['manual'],
|
||||
before,
|
||||
after,
|
||||
);
|
||||
|
||||
assert.equal(report.assessment, 'captured');
|
||||
assert.equal(report.totals.admitted, 8);
|
||||
assert.equal(report.totals.captured, 8);
|
||||
assert.equal(report.totals.failed, 0);
|
||||
assert.equal(report.totals.pending, 0);
|
||||
assert.equal(report.capturePermille, 1_000);
|
||||
assert.equal(JSON.stringify(report).includes('task'), false);
|
||||
});
|
||||
|
||||
test('separates fixed failure stages and incomplete admissions', () => {
|
||||
const value = fixture();
|
||||
const before = value.authority.snapshot(['manual', 'scheduled_node']);
|
||||
value.authority.admit('manual').failed('fact');
|
||||
value.authority.admit('manual').failed('accept');
|
||||
value.authority.admit('scheduled_node');
|
||||
value.advance();
|
||||
|
||||
const report = createLegacyShadowCaptureReport(
|
||||
'standalone',
|
||||
['manual', 'scheduled_node'],
|
||||
before,
|
||||
value.authority.snapshot(['manual', 'scheduled_node']),
|
||||
);
|
||||
|
||||
assert.equal(report.assessment, 'incomplete');
|
||||
assert.deepEqual(report.totals, {
|
||||
admitted: 3,
|
||||
captured: 0,
|
||||
failed: 2,
|
||||
pending: 1,
|
||||
failures: { fact: 1, observer: 0, initialization: 0, accept: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects cross-epoch, pending-baseline and incomplete-origin evidence', () => {
|
||||
const left = fixture();
|
||||
const right = new LegacyShadowCaptureAuthority(
|
||||
{ now: () => 1_750_200_001_000 },
|
||||
'019f75d2-2222-7222-8222-222222222222',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createLegacyShadowCaptureReport(
|
||||
'edge',
|
||||
['manual'],
|
||||
left.authority.snapshot(['manual']),
|
||||
right.snapshot(['manual']),
|
||||
),
|
||||
/cross process epochs/,
|
||||
);
|
||||
|
||||
left.authority.admit('manual');
|
||||
const pending = left.authority.snapshot(['manual']);
|
||||
left.advance();
|
||||
assert.throws(
|
||||
() =>
|
||||
createLegacyShadowCaptureReport(
|
||||
'edge',
|
||||
['manual'],
|
||||
pending,
|
||||
left.authority.snapshot(['manual']),
|
||||
),
|
||||
/starts with pending/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createLegacyShadowCaptureReport(
|
||||
'edge',
|
||||
['manual', 'boot'],
|
||||
left.authority.snapshot(['manual']),
|
||||
left.authority.snapshot(['manual']),
|
||||
),
|
||||
/window must be non-empty|coverage is incomplete/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { afterEach, test } = require('node:test');
|
||||
const {
|
||||
createLegacyShadowPrimaryGateReceipt,
|
||||
parseLegacyShadowPrimaryGateReceipt,
|
||||
} = require('../../back/runtime/domain/legacyShadowPrimaryGate');
|
||||
const {
|
||||
parseArguments,
|
||||
readEvidence,
|
||||
run,
|
||||
} = require('../../scripts/ql3-legacy-shadow-primary-gate.cjs');
|
||||
|
||||
const START = 1_750_400_000_000;
|
||||
const END = START + 60_000;
|
||||
const GENERATED = END + 6 * 60_000;
|
||||
const directories = [];
|
||||
|
||||
function captureEvidence(admitted = 8) {
|
||||
const outcomes = {
|
||||
completed: 0,
|
||||
cancelled: 0,
|
||||
abandoned: 0,
|
||||
markedLost: 0,
|
||||
repaired: 0,
|
||||
pending: 0,
|
||||
ambiguous: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
};
|
||||
return {
|
||||
schema: 'qinglong/legacy-shadow-capture-evidence@v1',
|
||||
profile: 'edge',
|
||||
startup: {
|
||||
schema: 'qinglong/legacy-shadow-startup-difference-report@v1',
|
||||
profile: 'edge',
|
||||
assessment: 'converged',
|
||||
configuredOriginCount: 1,
|
||||
coverage: { remaining: false },
|
||||
outcomes,
|
||||
byOrigin: [{ origin: 'manual', scanned: 0, ...outcomes }],
|
||||
},
|
||||
capture: {
|
||||
schema: 'qinglong/legacy-shadow-capture-report@v1',
|
||||
profile: 'edge',
|
||||
assessment: 'captured',
|
||||
epoch: '019f75d2-5555-7555-8555-555555555555',
|
||||
window: {
|
||||
basis: 'process_local_legacy_admission',
|
||||
startInclusiveMs: START,
|
||||
endExclusiveMs: END,
|
||||
},
|
||||
configuredOriginCount: 1,
|
||||
totals: {
|
||||
admitted,
|
||||
captured: admitted,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
failures: { fact: 0, observer: 0, initialization: 0, accept: 0 },
|
||||
},
|
||||
byOrigin: [
|
||||
{
|
||||
origin: 'manual',
|
||||
admitted,
|
||||
captured: admitted,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
failures: {
|
||||
fact: 0,
|
||||
observer: 0,
|
||||
initialization: 0,
|
||||
accept: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
capturePermille: 1_000,
|
||||
},
|
||||
qualification: {
|
||||
passed: true,
|
||||
startupConverged: true,
|
||||
originCoverageExact: true,
|
||||
captureComplete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function terminal(scanned = 8) {
|
||||
return {
|
||||
schema: 'qinglong/legacy-shadow-terminal-difference-report@v1',
|
||||
profile: 'edge',
|
||||
observedAtMs: GENERATED - 1,
|
||||
window: {
|
||||
basis: 'shadow_run_created_at',
|
||||
startInclusiveMs: START,
|
||||
endExclusiveMs: END,
|
||||
minimumSettlingAgeMs: 300_000,
|
||||
closed: true,
|
||||
},
|
||||
coverage: {
|
||||
direction: 'shadow_to_legacy',
|
||||
cohort: 'legacy_owned_shadow_runs',
|
||||
legacyWithoutShadow: 'not_measured',
|
||||
},
|
||||
scanned,
|
||||
remaining: false,
|
||||
evidenceComplete: true,
|
||||
assessment: 'matched',
|
||||
counts: { matched: scanned },
|
||||
byOrigin: [{ origin: 'manual', scanned, matched: scanned }],
|
||||
terminalAgreementPermille: 1_000,
|
||||
fullyComparablePermille: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
function resource() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
fixture: 'qinglong/legacy-shadow-resource-rollback-evidence@v1',
|
||||
profile: 'edge',
|
||||
workload: { mode: 'full', runtime: 'compiled_backend' },
|
||||
rollback: {
|
||||
performed: true,
|
||||
legacyContinued: true,
|
||||
shadowWritesStopped: true,
|
||||
databaseIntegrity: 'ok',
|
||||
},
|
||||
qualification: { passed: true, violations: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function input(overrides = {}) {
|
||||
return {
|
||||
profile: 'edge',
|
||||
generatedAtMs: GENERATED,
|
||||
capture: captureEvidence(),
|
||||
terminal: terminal(),
|
||||
resource: resource(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('combines capture, startup, terminal and rollback evidence into eligibility', () => {
|
||||
const receipt = createLegacyShadowPrimaryGateReceipt(input());
|
||||
|
||||
assert.equal(receipt.assessment, 'eligible');
|
||||
assert.deepEqual(receipt.violations, []);
|
||||
assert.deepEqual(receipt.counts, {
|
||||
admitted: 8,
|
||||
captured: 8,
|
||||
terminalScanned: 8,
|
||||
terminalMatched: 8,
|
||||
});
|
||||
assert.deepEqual(parseLegacyShadowPrimaryGateReceipt(receipt), receipt);
|
||||
});
|
||||
|
||||
test('fails closed for an undersized cohort, terminal drift and audit-only rollback', () => {
|
||||
const capture = captureEvidence(7);
|
||||
const terminalReport = terminal(6);
|
||||
const rollback = resource();
|
||||
rollback.workload.mode = 'audit-only';
|
||||
const receipt = createLegacyShadowPrimaryGateReceipt(
|
||||
input({ capture, terminal: terminalReport, resource: rollback }),
|
||||
);
|
||||
|
||||
assert.equal(receipt.assessment, 'ineligible');
|
||||
assert.deepEqual(receipt.violations, [
|
||||
'capture_sample_budget_invalid',
|
||||
'terminal_not_matched',
|
||||
'resource_not_compiled_full_rollback',
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLegacyShadowPrimaryGateReceipt({
|
||||
...receipt,
|
||||
assessment: 'eligible',
|
||||
}),
|
||||
/invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('CLI reads no-follow bounded inputs and publishes a no-replace receipt', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-primary-gate-'));
|
||||
directories.push(directory);
|
||||
const paths = Object.fromEntries(
|
||||
['capture', 'terminal', 'resource', 'output'].map((name) => [
|
||||
name,
|
||||
path.join(directory, `${name}.json`),
|
||||
]),
|
||||
);
|
||||
fs.writeFileSync(paths.capture, `${JSON.stringify(captureEvidence())}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(paths.terminal, `${JSON.stringify(terminal())}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(paths.resource, `${JSON.stringify(resource())}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
const options = parseArguments([
|
||||
'--profile=edge',
|
||||
`--capture=${paths.capture}`,
|
||||
`--terminal=${paths.terminal}`,
|
||||
`--resource=${paths.resource}`,
|
||||
`--output=${paths.output}`,
|
||||
`--generated-at-ms=${GENERATED}`,
|
||||
]);
|
||||
|
||||
const receipt = run(options);
|
||||
assert.equal(receipt.assessment, 'eligible');
|
||||
assert.equal(
|
||||
parseLegacyShadowPrimaryGateReceipt(readEvidence(paths.output).value)
|
||||
.assessment,
|
||||
'eligible',
|
||||
);
|
||||
assert.equal(fs.statSync(paths.output).mode & 0o777, 0o600);
|
||||
assert.throws(() => run(options), /EEXIST/);
|
||||
|
||||
const symlink = path.join(directory, 'capture-link.json');
|
||||
fs.symlinkSync(paths.capture, symlink);
|
||||
assert.throws(() => readEvidence(symlink), /ELOOP|symbolic/i);
|
||||
});
|
||||
@@ -125,6 +125,7 @@ test('serializes a successful observed process lifecycle without starting it', a
|
||||
failure: (failure) => failures.push(failure),
|
||||
});
|
||||
const observation = observer.begin(acceptedFact());
|
||||
assert.equal(await observation.captureSettled(), 'captured');
|
||||
observation.spawned({
|
||||
atMs: ACCEPTED_AT_MS + 1,
|
||||
pid: 4242,
|
||||
@@ -231,6 +232,7 @@ test('is default-off, fail-open, and rejects primary ownership', async () => {
|
||||
failure: (failure) => failures.push(failure),
|
||||
});
|
||||
const failed = shadow.begin(acceptedFact());
|
||||
assert.equal(await failed.captureSettled(), 'failed');
|
||||
failed.spawned({ atMs: ACCEPTED_AT_MS + 1 });
|
||||
failed.exited({ atMs: ACCEPTED_AT_MS + 2, exitCode: 0 });
|
||||
await failed.settled();
|
||||
|
||||
@@ -146,6 +146,12 @@ test(
|
||||
assert.equal(report.rollback.enabled.runDelta, 1);
|
||||
assert.equal(report.rollback.enabled.defaultObserverLoaded, true);
|
||||
assert.equal(report.rollback.enabled.repositoryLoaded, true);
|
||||
assert.equal(report.rollback.enabled.capture.assessment, 'captured');
|
||||
assert.equal(report.rollback.enabled.capture.totals.admitted, 1);
|
||||
assert.equal(report.rollback.enabled.capture.totals.captured, 1);
|
||||
assert.equal(report.rollback.enabled.capture.totals.failed, 0);
|
||||
assert.equal(report.rollback.enabled.capture.totals.pending, 0);
|
||||
assert.equal(report.rollback.enabled.capture.byOrigin[0].origin, 'system');
|
||||
assert.deepEqual(report.rollback.off.configuredOrigins, []);
|
||||
assert.equal(report.rollback.off.legacyExitCode, 0);
|
||||
assert.equal(report.rollback.off.runDelta, 0);
|
||||
|
||||
@@ -15,13 +15,19 @@ const NOW = 1_750_000_000_000;
|
||||
|
||||
function enabledManifest(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
revision: 'manual-primary-canary-1',
|
||||
enabled: true,
|
||||
approvedBy: 'operator:admin',
|
||||
approvedAtMs: NOW - 1_000,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
rollbackPlanRef: 'docs/runbooks/disable-primary.md',
|
||||
primaryGate: {
|
||||
schema: 'qinglong/legacy-shadow-primary-gate-reference@v1',
|
||||
origin: 'manual',
|
||||
receiptFile: 'manual-primary-gate.json',
|
||||
receiptSha256: 'a'.repeat(64),
|
||||
},
|
||||
rollout: {
|
||||
defaultMode: 'off',
|
||||
origins: { manual: 'primary' },
|
||||
@@ -120,7 +126,7 @@ test('parses a time-bounded, manual-only rollout manifest', () => {
|
||||
);
|
||||
|
||||
const disabled = parseRuntimeRolloutManifest(
|
||||
{ schemaVersion: 1, revision: 'disabled-1', enabled: false },
|
||||
{ schemaVersion: 2, revision: 'disabled-1', enabled: false },
|
||||
NOW,
|
||||
);
|
||||
assert.equal(disabled.policy.modeFor('manual'), 'off');
|
||||
@@ -128,6 +134,7 @@ test('parses a time-bounded, manual-only rollout manifest', () => {
|
||||
|
||||
test('rejects broad, stale, incomplete, and extensible rollout manifests', () => {
|
||||
const cases = [
|
||||
enabledManifest({ schemaVersion: 1 }),
|
||||
enabledManifest({
|
||||
rollout: {
|
||||
defaultMode: 'primary',
|
||||
@@ -135,6 +142,14 @@ test('rejects broad, stale, incomplete, and extensible rollout manifests', () =>
|
||||
allowLegacyFallbackBeforeStart: false,
|
||||
},
|
||||
}),
|
||||
enabledManifest({
|
||||
primaryGate: {
|
||||
schema: 'qinglong/legacy-shadow-primary-gate-reference@v1',
|
||||
origin: 'manual',
|
||||
receiptFile: '../escaped.json',
|
||||
receiptSha256: 'a'.repeat(64),
|
||||
},
|
||||
}),
|
||||
enabledManifest({
|
||||
rollout: {
|
||||
defaultMode: 'off',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
@@ -8,6 +9,9 @@ const { afterEach, test } = require('node:test');
|
||||
const {
|
||||
loadRuntimeRolloutManifest,
|
||||
} = require('../../back/runtime/adapters/fs/runtimeRolloutManifestLoader');
|
||||
const {
|
||||
createLegacyShadowPrimaryGateReceipt,
|
||||
} = require('../../back/runtime/domain/legacyShadowPrimaryGate');
|
||||
|
||||
const NOW = 1_750_000_000_000;
|
||||
const directories = [];
|
||||
@@ -18,15 +22,103 @@ async function fixturePath(name = 'qinglong3-rollout.json') {
|
||||
return path.join(directory, name);
|
||||
}
|
||||
|
||||
function enabledManifest() {
|
||||
return {
|
||||
function gateReceipt() {
|
||||
const start = NOW - 20_000;
|
||||
const end = NOW - 10_000;
|
||||
const startup = {
|
||||
schema: 'qinglong/legacy-shadow-startup-difference-report@v1',
|
||||
profile: 'edge',
|
||||
assessment: 'converged',
|
||||
configuredOriginCount: 1,
|
||||
coverage: { remaining: false },
|
||||
byOrigin: [{ origin: 'manual' }],
|
||||
};
|
||||
const capture = {
|
||||
schema: 'qinglong/legacy-shadow-capture-evidence@v1',
|
||||
profile: 'edge',
|
||||
startup,
|
||||
capture: {
|
||||
schema: 'qinglong/legacy-shadow-capture-report@v1',
|
||||
profile: 'edge',
|
||||
assessment: 'captured',
|
||||
configuredOriginCount: 1,
|
||||
window: {
|
||||
basis: 'process_local_legacy_admission',
|
||||
startInclusiveMs: start,
|
||||
endExclusiveMs: end,
|
||||
},
|
||||
totals: { admitted: 8, captured: 8, failed: 0, pending: 0 },
|
||||
byOrigin: [{ origin: 'manual' }],
|
||||
capturePermille: 1_000,
|
||||
},
|
||||
qualification: { passed: true },
|
||||
};
|
||||
const terminal = {
|
||||
schema: 'qinglong/legacy-shadow-terminal-difference-report@v1',
|
||||
profile: 'edge',
|
||||
observedAtMs: NOW - 3_000,
|
||||
window: { startInclusiveMs: start, endExclusiveMs: end, closed: true },
|
||||
coverage: {
|
||||
direction: 'shadow_to_legacy',
|
||||
cohort: 'legacy_owned_shadow_runs',
|
||||
legacyWithoutShadow: 'not_measured',
|
||||
},
|
||||
assessment: 'matched',
|
||||
scanned: 8,
|
||||
remaining: false,
|
||||
evidenceComplete: true,
|
||||
counts: { matched: 8 },
|
||||
byOrigin: [{ origin: 'manual', scanned: 8 }],
|
||||
terminalAgreementPermille: 1_000,
|
||||
fullyComparablePermille: 1_000,
|
||||
};
|
||||
const resource = {
|
||||
schemaVersion: 1,
|
||||
fixture: 'qinglong/legacy-shadow-resource-rollback-evidence@v1',
|
||||
profile: 'edge',
|
||||
workload: { mode: 'full', runtime: 'compiled_backend' },
|
||||
rollback: {
|
||||
performed: true,
|
||||
legacyContinued: true,
|
||||
shadowWritesStopped: true,
|
||||
databaseIntegrity: 'ok',
|
||||
},
|
||||
qualification: { passed: true, violations: [] },
|
||||
};
|
||||
return createLegacyShadowPrimaryGateReceipt({
|
||||
profile: 'edge',
|
||||
generatedAtMs: NOW - 2_000,
|
||||
capture,
|
||||
terminal,
|
||||
resource,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeGateReceipt(sourcePath, value = gateReceipt()) {
|
||||
const bytes = Buffer.from(`${JSON.stringify(value)}\n`);
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sourcePath), 'primary-gate.json'),
|
||||
bytes,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function enabledManifest(receiptSha256) {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
revision: 'manual-primary-canary-1',
|
||||
enabled: true,
|
||||
approvedBy: 'operator:admin',
|
||||
approvedAtMs: NOW - 1_000,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
rollbackPlanRef: 'docs/runbooks/disable-primary.md',
|
||||
primaryGate: {
|
||||
schema: 'qinglong/legacy-shadow-primary-gate-reference@v1',
|
||||
origin: 'manual',
|
||||
receiptFile: 'primary-gate.json',
|
||||
receiptSha256,
|
||||
},
|
||||
rollout: {
|
||||
defaultMode: 'off',
|
||||
origins: { manual: 'primary' },
|
||||
@@ -69,7 +161,8 @@ test('fails closed when the rollout file is absent', async () => {
|
||||
|
||||
test('loads an approved manifest and audits only bounded metadata', async () => {
|
||||
const sourcePath = await fixturePath();
|
||||
const raw = JSON.stringify(enabledManifest());
|
||||
const receiptSha256 = await writeGateReceipt(sourcePath);
|
||||
const raw = JSON.stringify(enabledManifest(receiptSha256));
|
||||
await fs.writeFile(sourcePath, raw);
|
||||
|
||||
const result = await loadRuntimeRolloutManifest(sourcePath, {
|
||||
@@ -79,10 +172,52 @@ test('loads an approved manifest and audits only bounded metadata', async () =>
|
||||
assert.equal(result.status, 'accepted');
|
||||
assert.equal(result.policy.modeFor('manual'), 'primary');
|
||||
assert.equal(result.audit.revision, 'manual-primary-canary-1');
|
||||
assert.equal(result.primaryGateReceipt.assessment, 'eligible');
|
||||
assert.match(result.audit.sourceSha256, /^[a-f0-9]{64}$/);
|
||||
assert.doesNotMatch(JSON.stringify(result.audit), /operator:admin|rollback/);
|
||||
});
|
||||
|
||||
test('rejects a missing, tampered or ineligible Primary gate receipt', async () => {
|
||||
const missingPath = await fixturePath('missing-gate-manifest.json');
|
||||
await fs.writeFile(
|
||||
missingPath,
|
||||
JSON.stringify(enabledManifest('a'.repeat(64))),
|
||||
);
|
||||
const missing = await loadRuntimeRolloutManifest(missingPath, {
|
||||
clock: { now: () => NOW },
|
||||
});
|
||||
assert.equal(missing.status, 'rejected');
|
||||
assert.equal(missing.audit.reasonCode, 'PRIMARY_GATE_READ_FAILED');
|
||||
|
||||
const tamperedPath = await fixturePath('tampered-gate-manifest.json');
|
||||
const tampered = gateReceipt();
|
||||
tampered.sources.terminal.counts.matched = 7;
|
||||
const tamperedDigest = await writeGateReceipt(tamperedPath, tampered);
|
||||
await fs.writeFile(
|
||||
tamperedPath,
|
||||
JSON.stringify(enabledManifest(tamperedDigest)),
|
||||
);
|
||||
const tamperedResult = await loadRuntimeRolloutManifest(tamperedPath, {
|
||||
clock: { now: () => NOW },
|
||||
});
|
||||
assert.equal(tamperedResult.status, 'rejected');
|
||||
assert.equal(tamperedResult.audit.reasonCode, 'PRIMARY_GATE_INVALID');
|
||||
|
||||
const invalidPath = await fixturePath('invalid-gate-manifest.json');
|
||||
const ineligible = {
|
||||
...gateReceipt(),
|
||||
assessment: 'ineligible',
|
||||
violations: ['terminal_not_matched'],
|
||||
};
|
||||
const digest = await writeGateReceipt(invalidPath, ineligible);
|
||||
await fs.writeFile(invalidPath, JSON.stringify(enabledManifest(digest)));
|
||||
const invalid = await loadRuntimeRolloutManifest(invalidPath, {
|
||||
clock: { now: () => NOW },
|
||||
});
|
||||
assert.equal(invalid.status, 'rejected');
|
||||
assert.equal(invalid.audit.reasonCode, 'PRIMARY_GATE_INVALID');
|
||||
});
|
||||
|
||||
test('rejects malformed and oversized files without exposing their contents', async () => {
|
||||
const malformedPath = await fixturePath('malformed.json');
|
||||
await fs.writeFile(malformedPath, '{"approvedBy":"secret"');
|
||||
|
||||
Reference in New Issue
Block a user