From be98d6753bff043c6f00fd9aea0741b369fc2150 Mon Sep 17 00:00:00 2001 From: whyour Date: Wed, 19 Aug 2026 03:27:32 +0800 Subject: [PATCH] feat(ql3): gate primary on shadow capture evidence --- back/app.ts | 23 +- .../fs/runtimeRolloutManifestLoader.ts | 66 +++ .../bootstrapDefaultManualPrimaryRuntime.ts | 21 +- .../bootstrapLegacyShadowCaptureEvidence.ts | 254 +++++++++++ .../legacyShadowCaptureAuthority.ts | 296 +++++++++++++ .../application/legacyShadowRunObserver.ts | 8 +- .../compatibility/legacyExecutionBridge.ts | 56 ++- .../runtime/domain/legacyShadowPrimaryGate.ts | 418 ++++++++++++++++++ back/runtime/domain/runtimeRolloutManifest.ts | 53 ++- back/runtime/ports/legacyExecutionObserver.ts | 1 + back/runtime/ports/runtimeRolloutLoader.ts | 6 +- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 41 +- ...-crontab-compatibility-and-shadow-write.md | 4 +- ...adow-resource-and-off-rollback-evidence.md | 3 +- ...adow-capture-authority-and-primary-gate.md | 100 +++++ docs/adr/README.md | 1 + package.json | 1 + scripts/ql3-legacy-shadow-primary-gate.cjs | 151 +++++++ .../ql3-legacy-shadow-resource-rollback.cjs | 23 +- ...tstrapDefaultManualPrimaryRuntime.test.cjs | 51 ++- ...tstrapLegacyShadowCaptureEvidence.test.cjs | 162 +++++++ ...LegacyShadowStartupReconciliation.test.cjs | 6 +- .../legacyShadowCaptureAuthority.test.cjs | 113 +++++ test/back/legacyShadowPrimaryGate.test.cjs | 231 ++++++++++ test/back/legacyShadowRun.test.cjs | 2 + .../ql3LegacyShadowResourceRollback.test.cjs | 6 + test/back/runtimeRollout.test.cjs | 19 +- .../runtimeRolloutManifestLoader.test.cjs | 141 +++++- 28 files changed, 2227 insertions(+), 30 deletions(-) create mode 100644 back/runtime/adapters/legacy/bootstrapLegacyShadowCaptureEvidence.ts create mode 100644 back/runtime/application/legacyShadowCaptureAuthority.ts create mode 100644 back/runtime/domain/legacyShadowPrimaryGate.ts create mode 100644 docs/adr/ADR-0453-origin-scoped-legacy-shadow-capture-authority-and-primary-gate.md create mode 100644 scripts/ql3-legacy-shadow-primary-gate.cjs create mode 100644 test/back/bootstrapLegacyShadowCaptureEvidence.test.cjs create mode 100644 test/back/legacyShadowCaptureAuthority.test.cjs create mode 100644 test/back/legacyShadowPrimaryGate.test.cjs diff --git a/back/app.ts b/back/app.ts index 921ac86b..69d53168 100644 --- a/back/app.ts +++ b/back/app.ts @@ -26,6 +26,9 @@ class Application { private manualPrimaryRuntime?: { stop(): Promise<'drained' | 'timed_out'>; }; + private legacyShadowCaptureEvidence?: { + close(): Promise; + }; private isShuttingDown = false; private workerMetadataMap = new Map(); 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 { + const evidence = this.legacyShadowCaptureEvidence; + this.legacyShadowCaptureEvidence = undefined; + if (!evidence) return; + await evidence.close(); + } } const app = new Application(); diff --git a/back/runtime/adapters/fs/runtimeRolloutManifestLoader.ts b/back/runtime/adapters/fs/runtimeRolloutManifestLoader.ts index c97d50d8..a4913baf 100644 --- a/back/runtime/adapters/fs/runtimeRolloutManifestLoader.ts +++ b/back/runtime/adapters/fs/runtimeRolloutManifestLoader.ts @@ -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 { + 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, diff --git a/back/runtime/adapters/legacy/bootstrapDefaultManualPrimaryRuntime.ts b/back/runtime/adapters/legacy/bootstrapDefaultManualPrimaryRuntime.ts index be1d176d..0c17fb95 100644 --- a/back/runtime/adapters/legacy/bootstrapDefaultManualPrimaryRuntime.ts +++ b/back/runtime/adapters/legacy/bootstrapDefaultManualPrimaryRuntime.ts @@ -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, diff --git a/back/runtime/adapters/legacy/bootstrapLegacyShadowCaptureEvidence.ts b/back/runtime/adapters/legacy/bootstrapLegacyShadowCaptureEvidence.ts new file mode 100644 index 00000000..df50a6be --- /dev/null +++ b/back/runtime/adapters/legacy/bootstrapLegacyShadowCaptureEvidence.ts @@ -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; +} + +export interface BootstrapLegacyShadowCaptureEvidenceOptions { + startup: LegacyShadowStartupAudit; + origins?: readonly ExecutionOrigin[]; + profile?: DeploymentProfile; + outputPath?: string; + snapshot?: ( + origins: readonly ExecutionOrigin[], + ) => LegacyShadowCaptureSnapshot; + write?: ( + outputPath: string, + evidence: LegacyShadowCaptureEvidence, + ) => Promise; + audit?: (record: LegacyShadowCaptureEvidenceAudit) => void | Promise; +} + +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 { + 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, + record: LegacyShadowCaptureEvidenceAudit, +): Promise { + 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 { + 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; + }, + }; +} diff --git a/back/runtime/application/legacyShadowCaptureAuthority.ts b/back/runtime/application/legacyShadowCaptureAuthority.ts new file mode 100644 index 00000000..9083502f --- /dev/null +++ b/back/runtime/application/legacyShadowCaptureAuthority.ts @@ -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; +} + +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 { + 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(); + 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); +} diff --git a/back/runtime/application/legacyShadowRunObserver.ts b/back/runtime/application/legacyShadowRunObserver.ts index 0bbc80b6..38356419 100644 --- a/back/runtime/application/legacyShadowRunObserver.ts +++ b/back/runtime/application/legacyShadowRunObserver.ts @@ -64,6 +64,7 @@ function classifyError(error: unknown): string { class SerialLegacyExecutionObservation implements TrackedLegacyExecutionObservation { + private readonly capture: Promise; private chain: Promise; 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 { diff --git a/back/runtime/compatibility/legacyExecutionBridge.ts b/back/runtime/compatibility/legacyExecutionBridge.ts index 17159aa3..b74c6b8a 100644 --- a/back/runtime/compatibility/legacyExecutionBridge.ts +++ b/back/runtime/compatibility/legacyExecutionBridge.ts @@ -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 | undefined; let defaultObserver: Promise | undefined; let defaultCorrelator: Promise | undefined; const failureCounters = new Map(); +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 { 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, 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 = ( 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); +} diff --git a/back/runtime/domain/legacyShadowPrimaryGate.ts b/back/runtime/domain/legacyShadowPrimaryGate.ts new file mode 100644 index 00000000..46ad63db --- /dev/null +++ b/back/runtime/domain/legacyShadowPrimaryGate.ts @@ -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 | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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, + 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'; diff --git a/back/runtime/domain/runtimeRolloutManifest.ts b/back/runtime/domain/runtimeRolloutManifest.ts index d9c1761a..46ab8a22 100644 --- a/back/runtime/domain/runtimeRolloutManifest.ts +++ b/back/runtime/domain/runtimeRolloutManifest.ts @@ -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; } @@ -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([ '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 { 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']), diff --git a/back/runtime/ports/legacyExecutionObserver.ts b/back/runtime/ports/legacyExecutionObserver.ts index ca5eb177..d9d7846a 100644 --- a/back/runtime/ports/legacyExecutionObserver.ts +++ b/back/runtime/ports/legacyExecutionObserver.ts @@ -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; diff --git a/back/runtime/ports/runtimeRolloutLoader.ts b/back/runtime/ports/runtimeRolloutLoader.ts index b3696239..0384e257 100644 --- a/back/runtime/ports/runtimeRolloutLoader.ts +++ b/back/runtime/ports/runtimeRolloutLoader.ts @@ -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 { diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 0b5e65eb..8e97dbda 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -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 全入口装配;完整回滚演练 | diff --git a/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md b/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md index 81a34e40..4a508259 100644 --- a/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md +++ b/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md @@ -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 写失败 diff --git a/docs/adr/ADR-0451-profile-bounded-legacy-shadow-resource-and-off-rollback-evidence.md b/docs/adr/ADR-0451-profile-bounded-legacy-shadow-resource-and-off-rollback-evidence.md index 3e40b82a..f4e53fcb 100644 --- a/docs/adr/ADR-0451-profile-bounded-legacy-shadow-resource-and-off-rollback-evidence.md +++ b/docs/adr/ADR-0451-profile-bounded-legacy-shadow-resource-and-off-rollback-evidence.md @@ -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 结果替代。 diff --git a/docs/adr/ADR-0453-origin-scoped-legacy-shadow-capture-authority-and-primary-gate.md b/docs/adr/ADR-0453-origin-scoped-legacy-shadow-capture-authority-and-primary-gate.md new file mode 100644 index 00000000..cc91c4d9 --- /dev/null +++ b/docs/adr/ADR-0453-origin-scoped-legacy-shadow-capture-authority-and-primary-gate.md @@ -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=.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。 diff --git a/docs/adr/README.md b/docs/adr/README.md index e814d386..de04a052 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 待执行) | ## 规则 diff --git a/package.json b/package.json index 257a345a..b3006f92 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ql3-legacy-shadow-primary-gate.cjs b/scripts/ql3-legacy-shadow-primary-gate.cjs new file mode 100644 index 00000000..09660c78 --- /dev/null +++ b/scripts/ql3-legacy-shadow-primary-gate.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; + } +} diff --git a/scripts/ql3-legacy-shadow-resource-rollback.cjs b/scripts/ql3-legacy-shadow-resource-rollback.cjs index 2ec6994f..3c80a817 100644 --- a/scripts/ql3-legacy-shadow-resource-rollback.cjs +++ b/scripts/ql3-legacy-shadow-resource-rollback.cjs @@ -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( diff --git a/test/back/bootstrapDefaultManualPrimaryRuntime.test.cjs b/test/back/bootstrapDefaultManualPrimaryRuntime.test.cjs index d0c44d31..0b6610f5 100644 --- a/test/back/bootstrapDefaultManualPrimaryRuntime.test.cjs +++ b/test/back/bootstrapDefaultManualPrimaryRuntime.test.cjs @@ -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; diff --git a/test/back/bootstrapLegacyShadowCaptureEvidence.test.cjs b/test/back/bootstrapLegacyShadowCaptureEvidence.test.cjs new file mode 100644 index 00000000..523cec32 --- /dev/null +++ b/test/back/bootstrapLegacyShadowCaptureEvidence.test.cjs @@ -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'); +}); diff --git a/test/back/bootstrapLegacyShadowStartupReconciliation.test.cjs b/test/back/bootstrapLegacyShadowStartupReconciliation.test.cjs index 69c75bbc..4a33166c 100644 --- a/test/back/bootstrapLegacyShadowStartupReconciliation.test.cjs +++ b/test/back/bootstrapLegacyShadowStartupReconciliation.test.cjs @@ -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); }); diff --git a/test/back/legacyShadowCaptureAuthority.test.cjs b/test/back/legacyShadowCaptureAuthority.test.cjs new file mode 100644 index 00000000..c19b435f --- /dev/null +++ b/test/back/legacyShadowCaptureAuthority.test.cjs @@ -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/, + ); +}); diff --git a/test/back/legacyShadowPrimaryGate.test.cjs b/test/back/legacyShadowPrimaryGate.test.cjs new file mode 100644 index 00000000..e85e2ad9 --- /dev/null +++ b/test/back/legacyShadowPrimaryGate.test.cjs @@ -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); +}); diff --git a/test/back/legacyShadowRun.test.cjs b/test/back/legacyShadowRun.test.cjs index 196772fa..969bc32a 100644 --- a/test/back/legacyShadowRun.test.cjs +++ b/test/back/legacyShadowRun.test.cjs @@ -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(); diff --git a/test/back/ql3LegacyShadowResourceRollback.test.cjs b/test/back/ql3LegacyShadowResourceRollback.test.cjs index a9db7545..b0682830 100644 --- a/test/back/ql3LegacyShadowResourceRollback.test.cjs +++ b/test/back/ql3LegacyShadowResourceRollback.test.cjs @@ -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); diff --git a/test/back/runtimeRollout.test.cjs b/test/back/runtimeRollout.test.cjs index 57441734..1b146c74 100644 --- a/test/back/runtimeRollout.test.cjs +++ b/test/back/runtimeRollout.test.cjs @@ -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', diff --git a/test/back/runtimeRolloutManifestLoader.test.cjs b/test/back/runtimeRolloutManifestLoader.test.cjs index 74e8ca6b..ff08cb0e 100644 --- a/test/back/runtimeRolloutManifestLoader.test.cjs +++ b/test/back/runtimeRolloutManifestLoader.test.cjs @@ -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"');