diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index b87f8819..8d069cb2 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,9 +11,9 @@ 最新增量证据(2026-08-21): -- D-391/ADR-0484(契约已冻结,待实现):`reconciliation_planned` 仍是内容无关 summary,不能为人工裁决泄漏明细或获得 import authority。 - 下一切片在既有 Local Owner 的 `deployment/reconciliation/review/` 增加 - `reconciliation.review.prepare|diagnostics|commit|verify`,以 +- D-391/ADR-0484(第一切片已实现,commit/verify 待实现):`reconciliation_planned` 仍是内容无关 summary,不能为人工裁决泄漏明细或 + 获得 import authority。既有 Local Owner 已在 `deployment/reconciliation/review/` 增加 + `reconciliation.review.prepare|diagnostics`,并为后续 commit/verify 冻结 `reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把 一个 database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 只返回 page digest、计数和 offset,不含路径、名称或 fact digest。commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、 @@ -22,7 +22,10 @@ exact review file/plan/bundle/head fence 签名;terminal review 只保存签名 authorization、compact counts/digest 和 content-free receipt。 verify 不打开 SQLite。实现不新增 package/dependency/binary/daemon,不把文件平铺回 `src/` 根,也不调用 DML、Secret 解密、Docker/init/ network。后续领域 adapter 必须消费 exact `reviewDigest` 后重新认证和授权,分别定义 backup、prepare/commit、幂等与 rollback,不能把 - review completion 当作 reconciliation completion。 + review completion 当作 reconciliation completion。当前聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`,Local Owner + `250 total / 243 pass / 7 conditional skip / 0 fail`,tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`, + 18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact 和真实 Docker readonly `2/2` 全通过。workspace 仍为 18 + packages,Local Owner `149 source / 148 nested / 1 root binary entry`;新增 3 个源码全部位于 review 子目录,基础常驻 closure 不增长。 - D-390/ADR-0483(已接受):既有 Local Owner 已实现密封 capture 的严格只读消费与独立 `reconciliation.plan.prepare|commit|verify`。capture v2 使用 SQLite 可识别的固定 `target.sqlite* / legacy.sqlite* / recovery.sqlite` 物理名和 `0400/0500` terminal seal;main-only 走 immutable readonly,WAL+SHM 完整配对走普通 readonly,hot diff --git a/docs/adr/ADR-0484-private-reconciliation-diagnostics-and-reviewed-decision.md b/docs/adr/ADR-0484-private-reconciliation-diagnostics-and-reviewed-decision.md index d5b5a7ee..7e4d005c 100644 --- a/docs/adr/ADR-0484-private-reconciliation-diagnostics-and-reviewed-decision.md +++ b/docs/adr/ADR-0484-private-reconciliation-diagnostics-and-reviewed-decision.md @@ -119,6 +119,23 @@ import Local SQLite mutation、adoption publisher 或 runtime execution authorit 运行时最多一个 SQLite handle、一个 decision iterator、64 KiB hash/read buffer、64 条 page record 和固定八领域 counters。diagnostics output 不进入基础 Edge/Standalone artifact;Cluster/PostgreSQL/Kubernetes 不读取 Local review root、authorization 或 reviewer identity。 +## 当前实现进度 + +D-391 第一切片已实现 `review.prepare` 与 `review.diagnostics`。prepare 读取并重验 exact terminal plan/bundle,以 instance CAS 建立 +`reconciliation_review_prepared` 唯一 fence,覆盖 head response loss、第二 review、rollback 和 restart 拒绝。diagnostics 在每次 +SQLite open 前后重验密封资产,只为一个 database/domain/fact-kind 发布最多 64 条 owner-only page;Secret、identity、history 和 unknown +facts 固定 blocked,未知表不读取 row,terminal result 不返回路径、名称或 fact digest。page 使用 256 KiB 固定上限、deterministic +stage、hard-link no-replace 与 fsync,重复请求只能得到 byte-exact existing page。 + +验证结果:聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner +`250 total / 243 pass / 7 conditional skip / 0 fail`;tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`; +18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact audit 与真实 Docker readonly `2/2` 均通过。workspace 仍为 18 +packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `149 source / 148 nested / 1 root binary entry`,新增 +3 个源码全部位于 `deployment/reconciliation/review/`,没有新增 dependency 或常驻对象,基础 Edge/Standalone closure 未增长。 + +`review.commit`、强认证 User/issuer keyring authorization、terminal seal、`reconciliation_reviewed` 推进和 `review.verify` 尚未实现,因此本 +ADR 继续保持 Proposed;当前 prepared review 不授予任何 import、rollback 或 restart authority。 + ## 被拒绝的替代方案 ### 把对象名和冲突列表直接加入 plan diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts index 725d08d0..905255a8 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts @@ -27,6 +27,8 @@ export type LocalCutoverInstanceHeadState = | 'reconciliation_captured' | 'reconciliation_plan_prepared' | 'reconciliation_planned' + | 'reconciliation_review_prepared' + | 'reconciliation_reviewed' | 'rollback_prepared' | 'legacy_restart_requested' | 'legacy_running' @@ -162,6 +164,8 @@ function parseHead(value: unknown): Readonly { head.state !== 'reconciliation_captured' && head.state !== 'reconciliation_plan_prepared' && head.state !== 'reconciliation_planned' && + head.state !== 'reconciliation_review_prepared' && + head.state !== 'reconciliation_reviewed' && head.state !== 'rollback_prepared' && head.state !== 'legacy_restart_requested' && head.state !== 'legacy_running' && @@ -332,6 +336,8 @@ export function advanceLocalCutoverInstanceHead( | 'reconciliation_captured' | 'reconciliation_plan_prepared' | 'reconciliation_planned' + | 'reconciliation_review_prepared' + | 'reconciliation_reviewed' | 'rollback_prepared' | 'legacy_restart_requested' | 'legacy_running' @@ -374,6 +380,8 @@ export function advanceLocalCutoverInstanceHead( current.state === 'reconciliation_captured' || current.state === 'reconciliation_plan_prepared' || current.state === 'reconciliation_planned' || + current.state === 'reconciliation_review_prepared' || + current.state === 'reconciliation_reviewed' || current.state === 'legacy_restart_requested' || current.state === 'legacy_running' || current.state === 'legacy_ready') @@ -397,6 +405,10 @@ export function advanceLocalCutoverInstanceHead( current.state === 'reconciliation_captured') || (state === 'reconciliation_planned' && current.state === 'reconciliation_plan_prepared') || + (state === 'reconciliation_review_prepared' && + current.state === 'reconciliation_planned') || + (state === 'reconciliation_reviewed' && + current.state === 'reconciliation_review_prepared') || (state === 'rollback_prepared' && current.state === 'target_stopped') || (state === 'legacy_restart_requested' && current.state === 'rollback_prepared') || diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 760e5aa8..9e50d905 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -111,6 +111,12 @@ import { verifyLocalReconciliationPlan, verifyLocalReconciliationPlanCommandFile, } from './reconciliation/planning/preparation'; +import { + prepareLocalReconciliationReview, + prepareLocalReconciliationReviewCommandFile, + writeLocalReconciliationReviewDiagnostics, + writeLocalReconciliationReviewDiagnosticsCommandFile, +} from './reconciliation/review/preparation'; export { commitLocalReconciliationPlan, @@ -119,6 +125,10 @@ export { prepareLocalReconciliationPlanCommandFile, verifyLocalReconciliationPlan, verifyLocalReconciliationPlanCommandFile, + prepareLocalReconciliationReview, + prepareLocalReconciliationReviewCommandFile, + writeLocalReconciliationReviewDiagnostics, + writeLocalReconciliationReviewDiagnosticsCommandFile, }; export { @@ -160,6 +170,30 @@ export { type LocalReconciliationCaptureVerifyCommand, type LocalReconciliationStoppedAuthority, } from './reconciliation/contract'; +export { + LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS, + normalizeLocalReconciliationReviewDiagnosticsCommand, + normalizeLocalReconciliationReviewPrepareCommand, + type LocalReconciliationDiagnosticFactKind, + type LocalReconciliationReviewDiagnosticsCommand, + type LocalReconciliationReviewDiagnosticsResult, + type LocalReconciliationReviewOptions, + type LocalReconciliationReviewPrepareCommand, + type LocalReconciliationReviewPrepareResult, +} from './reconciliation/review/contract'; +export { + localReconciliationReviewDirectory, + normalizeLocalReconciliationReviewIntent, + readLocalReconciliationReviewIntent, + type LocalReconciliationReviewDependencies, + type LocalReconciliationReviewIntent, +} from './reconciliation/review/preparation'; +export { + type LocalReconciliationDiagnosticDecisionRequirement, + type LocalReconciliationDiagnosticFact, + type LocalReconciliationDiagnosticPage, + type LocalReconciliationDiagnosticReason, +} from './reconciliation/review/diagnostics'; export { LOCAL_RECONCILIATION_PLAN_DOMAINS, normalizeLocalReconciliationPlanCommitCommand, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts index 0f186506..0cb5ce3a 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts @@ -15,10 +15,12 @@ import { prepareLocalServiceManagerLegacyRollbackCommandFile, prepareLocalReconciliationCaptureCommandFile, prepareLocalReconciliationPlanCommandFile, + prepareLocalReconciliationReviewCommandFile, commitLocalReconciliationCaptureCommandFile, commitLocalReconciliationPlanCommandFile, verifyLocalReconciliationCaptureCommandFile, verifyLocalReconciliationPlanCommandFile, + writeLocalReconciliationReviewDiagnosticsCommandFile, prepareLocalDeploymentCommandFile, proveLocalDeploymentLegacyReadinessCommandFile, restoreLocalDeploymentComposeCommitCommandFile, @@ -33,7 +35,7 @@ import { } from './localDeployment'; const USAGE = - 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; + 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -68,6 +70,8 @@ async function main(argv: readonly string[]): Promise { argv[0] !== 'reconciliation-plan-prepare' && argv[0] !== 'reconciliation-plan-commit' && argv[0] !== 'reconciliation-plan-verify' && + argv[0] !== 'reconciliation-review-prepare' && + argv[0] !== 'reconciliation-review-diagnostics' && argv[0] !== 'compose-revision' && argv[0] !== 'compose-preflight' && argv[0] !== 'compose-apply' && @@ -150,6 +154,10 @@ async function main(argv: readonly string[]): Promise { ? commitLocalReconciliationPlanCommandFile(argv[2]!) : argv[0] === 'reconciliation-plan-verify' ? verifyLocalReconciliationPlanCommandFile(argv[2]!) + : argv[0] === 'reconciliation-review-prepare' + ? prepareLocalReconciliationReviewCommandFile(argv[2]!) + : argv[0] === 'reconciliation-review-diagnostics' + ? writeLocalReconciliationReviewDiagnosticsCommandFile(argv[2]!) : argv[0] === 'compose-revision' ? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!) : argv[0] === 'compose-preflight' diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/inventory.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/inventory.ts index 38ce8ecc..8f0e33b1 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/inventory.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/inventory.ts @@ -136,6 +136,13 @@ function classify( return kind === 'legacy' ? legacyDomain(name) : targetDomain(name); } +export function classifyLocalReconciliationFact( + kind: LocalReconciliationSealedDatabaseKind, + name: string, +): LocalReconciliationPlanDomain { + return classify(kind, name); +} + function quotedIdentifier(name: string): string { return `"${name.replaceAll('"', '""')}"`; } diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/preparation.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/preparation.ts index bf8c9d53..fd12bf87 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/preparation.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/preparation.ts @@ -448,6 +448,35 @@ function readTerminal( return Object.freeze({ plan, receipt }); } +export interface LocalReconciliationPlanTerminal { + readonly intent: Readonly; + readonly bundle: Readonly; + readonly plan: Readonly; + readonly receipt: Readonly; +} + +export function readLocalReconciliationPlanTerminal( + planRoot: string, + planId: string, + uid: number, +): Readonly { + const paths = planPaths(planRoot, planId); + const intent = readLocalReconciliationPlanIntent(planRoot, planId); + if ( + intent.command.options.planRoot !== planRoot || + intent.command.request.planId !== planId + ) { + configurationError('terminal reconciliation plan path binding drifted'); + } + const bundle = inspectLocalReconciliationSealedBundle( + intent.command.options.captureRoot, + intent.command.request.captureId, + uid, + ); + const terminal = readTerminal(paths, intent, bundle, uid); + return Object.freeze({ intent, bundle, ...terminal }); +} + function result( operation: LocalReconciliationPlanTerminalResult['operation'], status: LocalReconciliationPlanTerminalResult['status'], diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/contract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/contract.ts new file mode 100644 index 00000000..e814d126 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/contract.ts @@ -0,0 +1,315 @@ +import path from 'node:path'; + +import { currentIdentity } from '../../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../../foundation/error'; +import { + LOCAL_RECONCILIATION_PLAN_DOMAINS, + type LocalReconciliationPlanDomain, +} from '../planning/contract'; + +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/; +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const MAX_PATH_BYTES = 4_096; + +export const LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS = Object.freeze([ + 'schema_object', + 'table', +] as const); + +export type LocalReconciliationDiagnosticFactKind = + (typeof LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS)[number]; + +export interface LocalReconciliationReviewOptions { + readonly deploymentRoot: string; + readonly captureRoot: string; + readonly planRoot: string; + readonly reviewRoot: string; + readonly allowRootService: boolean; +} + +export interface LocalReconciliationReviewPrepareCommand { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.review.prepare'; + readonly options: Readonly; + readonly request: Readonly<{ + reviewId: string; + planId: string; + expectedPlanDigest: string; + expectedHeadDigest: string; + preparedAtMs: number; + }>; +} + +export interface LocalReconciliationReviewDiagnosticsCommand { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.review.diagnostics'; + readonly options: Readonly; + readonly request: Readonly<{ + reviewId: string; + expectedPreparationDigest: string; + database: 'legacy' | 'target'; + domain: LocalReconciliationPlanDomain; + factKind: LocalReconciliationDiagnosticFactKind; + offset: number; + limit: number; + outputPath: string; + }>; +} + +export interface LocalReconciliationReviewPrepareResult { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.review.prepare'; + readonly status: 'prepared' | 'existing'; + readonly state: 'reconciliation_review_prepared'; + readonly reviewId: string; + readonly preparationDigest: string; + readonly instanceHeadDigest: string; +} + +export interface LocalReconciliationReviewDiagnosticsResult { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.review.diagnostics'; + readonly status: 'prepared' | 'existing'; + readonly state: 'reconciliation_review_prepared'; + readonly reviewId: string; + readonly pageDigest: string; + readonly recordCount: number; + readonly complete: boolean; + readonly nextOffset: number | null; + readonly instanceHeadDigest: string; +} + +function configurationError(message: string): never { + throw new LocalDeploymentConfigurationError(message); +} + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function safeAbsolutePath(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + !path.isAbsolute(value) || + path.normalize(value) !== value || + path.parse(value).root === value || + value.includes('\0') || + value.includes('//') || + !SAFE_PATH_PATTERN.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES + ) { + configurationError(`${label} must be a safe non-root absolute path`); + } + return value; +} + +function overlaps(left: string, right: string): boolean { + const relative = path.relative(left, right); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function digest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + configurationError(`${label} must be a SHA-256 digest`); + } + return value; +} + +function identifier(value: unknown, label: string): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + configurationError(`${label} must be a UUID v4`); + } + return value; +} + +function normalizeOptions(value: unknown): Readonly { + const options = object(value, 'options'); + exact( + options, + [ + 'allowRootService', + 'captureRoot', + 'deploymentRoot', + 'planRoot', + 'reviewRoot', + ], + 'options', + ); + const identity = currentIdentity(); + if ( + typeof options.allowRootService !== 'boolean' || + (identity.uid === 0) !== options.allowRootService + ) { + configurationError('reconciliation review command identity is invalid'); + } + const roots = [ + safeAbsolutePath(options.deploymentRoot, 'deploymentRoot'), + safeAbsolutePath(options.captureRoot, 'captureRoot'), + safeAbsolutePath(options.planRoot, 'planRoot'), + safeAbsolutePath(options.reviewRoot, 'reviewRoot'), + ]; + for (let left = 0; left < roots.length; left += 1) { + for (let right = left + 1; right < roots.length; right += 1) { + if (overlaps(roots[left]!, roots[right]!) || overlaps(roots[right]!, roots[left]!)) { + configurationError('deployment, capture, plan and review roots must not overlap'); + } + } + } + return Object.freeze({ + deploymentRoot: roots[0]!, + captureRoot: roots[1]!, + planRoot: roots[2]!, + reviewRoot: roots[3]!, + allowRootService: options.allowRootService, + }); +} + +function normalizeCommand( + value: unknown, + operation: + | 'local.deployment.reconciliation.review.prepare' + | 'local.deployment.reconciliation.review.diagnostics', +): Readonly<{ + options: Readonly; + request: Record; +}> { + const command = object(value, 'reconciliation review command'); + exact(command, ['operation', 'options', 'request', 'schemaVersion'], 'command'); + if (command.schemaVersion !== 1 || command.operation !== operation) { + configurationError('reconciliation review command is invalid'); + } + return Object.freeze({ + options: normalizeOptions(command.options), + request: object(command.request, 'request'), + }); +} + +export function normalizeLocalReconciliationReviewPrepareCommand( + value: unknown, +): Readonly { + const command = normalizeCommand( + value, + 'local.deployment.reconciliation.review.prepare', + ); + exact( + command.request, + [ + 'expectedHeadDigest', + 'expectedPlanDigest', + 'planId', + 'preparedAtMs', + 'reviewId', + ], + 'request', + ); + if ( + !Number.isSafeInteger(command.request.preparedAtMs) || + (command.request.preparedAtMs as number) < 0 + ) { + configurationError('reconciliation review prepare command is invalid'); + } + return Object.freeze({ + schemaVersion: 1 as const, + operation: 'local.deployment.reconciliation.review.prepare' as const, + options: command.options, + request: Object.freeze({ + reviewId: identifier(command.request.reviewId, 'reviewId'), + planId: identifier(command.request.planId, 'planId'), + expectedPlanDigest: digest( + command.request.expectedPlanDigest, + 'expectedPlanDigest', + ), + expectedHeadDigest: digest( + command.request.expectedHeadDigest, + 'expectedHeadDigest', + ), + preparedAtMs: command.request.preparedAtMs as number, + }), + }); +} + +export function normalizeLocalReconciliationReviewDiagnosticsCommand( + value: unknown, +): Readonly { + const command = normalizeCommand( + value, + 'local.deployment.reconciliation.review.diagnostics', + ); + exact( + command.request, + [ + 'database', + 'domain', + 'expectedPreparationDigest', + 'factKind', + 'limit', + 'offset', + 'outputPath', + 'reviewId', + ], + 'request', + ); + if ( + (command.request.database !== 'legacy' && + command.request.database !== 'target') || + !LOCAL_RECONCILIATION_PLAN_DOMAINS.includes( + command.request.domain as LocalReconciliationPlanDomain, + ) || + !LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS.includes( + command.request.factKind as LocalReconciliationDiagnosticFactKind, + ) || + !Number.isSafeInteger(command.request.offset) || + (command.request.offset as number) < 0 || + (command.request.offset as number) > 4_096 || + !Number.isSafeInteger(command.request.limit) || + (command.request.limit as number) < 1 || + (command.request.limit as number) > 64 + ) { + configurationError('reconciliation review diagnostics command is invalid'); + } + return Object.freeze({ + schemaVersion: 1 as const, + operation: 'local.deployment.reconciliation.review.diagnostics' as const, + options: command.options, + request: Object.freeze({ + reviewId: identifier(command.request.reviewId, 'reviewId'), + expectedPreparationDigest: digest( + command.request.expectedPreparationDigest, + 'expectedPreparationDigest', + ), + database: command.request.database, + domain: command.request.domain as LocalReconciliationPlanDomain, + factKind: + command.request.factKind as LocalReconciliationDiagnosticFactKind, + offset: command.request.offset as number, + limit: command.request.limit as number, + outputPath: safeAbsolutePath(command.request.outputPath, 'outputPath'), + }), + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts new file mode 100644 index 00000000..3373f40f --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts @@ -0,0 +1,494 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; + +import { LocalDeploymentConfigurationError } from '../../foundation/error'; +import { validatePrivateDirectory } from '../../foundation/files'; +import { cutoverDigest } from '../../cutover/targetEvidence'; +import { classifyLocalReconciliationFact } from '../planning/inventory'; +import type { LocalReconciliationPlanDomain } from '../planning/contract'; +import type { + LocalReconciliationSealedBundle, + LocalReconciliationSealedDatabaseKind, +} from '../sealed-bundle/reader'; +import type { + LocalReconciliationDiagnosticFactKind, + LocalReconciliationReviewDiagnosticsCommand, +} from './contract'; + +const PAGE_SCHEMA = 'qinglong3-local-reconciliation-diagnostic-page'; +const FACT_SCHEMA = 'qinglong3-local-reconciliation-diagnostic-fact'; +const MAX_PAGE_BYTES = 256 * 1024; +const MAX_NAME_BYTES = 1_024; + +export type LocalReconciliationDiagnosticDecisionRequirement = + | 'informational' + | 'required' + | 'blocked'; + +export type LocalReconciliationDiagnosticReason = + | 'catalog_evidence' + | 'reviewable_fact' + | 'unknown_schema' + | 'secret_custody_required' + | 'historical_integrity_required' + | 'identity_custody_required'; + +export interface LocalReconciliationDiagnosticFact { + readonly schema: typeof FACT_SCHEMA; + readonly schemaVersion: 1; + readonly ordinal: number; + readonly database: LocalReconciliationSealedDatabaseKind; + readonly domain: LocalReconciliationPlanDomain; + readonly factKind: LocalReconciliationDiagnosticFactKind; + readonly objectType: string; + readonly name: string; + readonly tableName: string; + readonly rowCount: string | null; + readonly decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement; + readonly reason: LocalReconciliationDiagnosticReason; + readonly factDigest: string; +} + +export interface LocalReconciliationDiagnosticPage { + readonly schema: typeof PAGE_SCHEMA; + readonly schemaVersion: 1; + readonly state: 'reconciliation_review_prepared'; + readonly reviewId: string; + readonly planId: string; + readonly planDigest: string; + readonly preparationDigest: string; + readonly bundleDigest: string; + readonly bundleFingerprintDigest: string; + readonly database: LocalReconciliationSealedDatabaseKind; + readonly domain: LocalReconciliationPlanDomain; + readonly factKind: LocalReconciliationDiagnosticFactKind; + readonly offset: number; + readonly limit: number; + readonly recordCount: number; + readonly complete: boolean; + readonly nextOffset: number | null; + readonly records: readonly Readonly[]; + readonly pageDigest: string; +} + +export interface BuildLocalReconciliationDiagnosticPageOptions { + readonly reviewId: string; + readonly planId: string; + readonly planDigest: string; + readonly preparationDigest: string; + readonly bundle: Readonly; + readonly command: Readonly; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError(message, { cause }); +} + +function quotedIdentifier(name: string): string { + return `"${name.replaceAll('"', '""')}"`; +} + +function requirement( + domain: LocalReconciliationPlanDomain, +): Readonly<{ + decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement; + reason: LocalReconciliationDiagnosticReason; +}> { + if (domain === 'unknown') { + return Object.freeze({ + decisionRequirement: 'blocked' as const, + reason: 'unknown_schema' as const, + }); + } + if (domain === 'secret_and_config') { + return Object.freeze({ + decisionRequirement: 'blocked' as const, + reason: 'secret_custody_required' as const, + }); + } + if (domain === 'run_history') { + return Object.freeze({ + decisionRequirement: 'blocked' as const, + reason: 'historical_integrity_required' as const, + }); + } + if (domain === 'identity_policy_audit') { + return Object.freeze({ + decisionRequirement: 'blocked' as const, + reason: 'identity_custody_required' as const, + }); + } + if (domain === 'schema_lineage') { + return Object.freeze({ + decisionRequirement: 'informational' as const, + reason: 'catalog_evidence' as const, + }); + } + return Object.freeze({ + decisionRequirement: 'required' as const, + reason: 'reviewable_fact' as const, + }); +} + +function rowCount( + client: DatabaseSync, + domain: LocalReconciliationPlanDomain, + tableName: string, + objectType: string, +): string | null { + if (domain === 'unknown' || objectType !== 'table') return null; + const counted = client + .prepare( + `SELECT CAST(COUNT(*) AS TEXT) AS countText FROM ${quotedIdentifier( + tableName, + )}`, + ) + .get() as { readonly countText?: unknown } | undefined; + if ( + typeof counted?.countText !== 'string' || + !/^(?:0|[1-9][0-9]*)$/.test(counted.countText) + ) { + configurationError('diagnostic table row count drifted'); + } + return counted.countText; +} + +function fact( + ordinal: number, + database: LocalReconciliationSealedDatabaseKind, + domain: LocalReconciliationPlanDomain, + factKind: LocalReconciliationDiagnosticFactKind, + objectType: string, + name: string, + tableName: string, + count: string | null, +): Readonly { + const review = requirement(domain); + const payload = Object.freeze({ + schema: FACT_SCHEMA, + schemaVersion: 1 as const, + ordinal, + database, + domain, + factKind, + objectType, + name, + tableName, + rowCount: count, + decisionRequirement: review.decisionRequirement, + reason: review.reason, + }); + return Object.freeze({ ...payload, factDigest: cutoverDigest(payload) }); +} + +function schemaFacts( + client: DatabaseSync, + database: LocalReconciliationSealedDatabaseKind, + domain: LocalReconciliationPlanDomain, + offset: number, + limit: number, +): Readonly<{ records: readonly Readonly[]; complete: boolean }> { + const rows = client + .prepare( + `SELECT type, name, tbl_name AS tableName + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type, name + LIMIT 4097`, + ) + .iterate() as IterableIterator<{ + readonly type?: unknown; + readonly name?: unknown; + readonly tableName?: unknown; + }>; + const records: LocalReconciliationDiagnosticFact[] = []; + let seen = 0; + let catalog = 0; + let complete = true; + for (const row of rows) { + catalog += 1; + if (catalog > 4_096) configurationError('diagnostic schema budget is exceeded'); + if ( + typeof row.type !== 'string' || + typeof row.name !== 'string' || + typeof row.tableName !== 'string' || + Buffer.byteLength(row.name, 'utf8') > MAX_NAME_BYTES || + Buffer.byteLength(row.tableName, 'utf8') > MAX_NAME_BYTES + ) { + configurationError('diagnostic schema catalog drifted'); + } + if (classifyLocalReconciliationFact(database, row.tableName) !== domain) { + continue; + } + const ordinal = seen + 1; + seen += 1; + if (seen <= offset) continue; + if (records.length >= limit) { + complete = false; + break; + } + records.push( + fact( + ordinal, + database, + domain, + 'schema_object', + row.type, + row.name, + row.tableName, + null, + ), + ); + } + return Object.freeze({ records: Object.freeze(records), complete }); +} + +function tableFacts( + client: DatabaseSync, + database: LocalReconciliationSealedDatabaseKind, + domain: LocalReconciliationPlanDomain, + offset: number, + limit: number, +): Readonly<{ records: readonly Readonly[]; complete: boolean }> { + const rows = client + .prepare( + `SELECT name, type + FROM pragma_table_list + WHERE schema = 'main' AND name NOT LIKE 'sqlite_%' + ORDER BY name + LIMIT 513`, + ) + .iterate() as IterableIterator<{ + readonly name?: unknown; + readonly type?: unknown; + }>; + const records: LocalReconciliationDiagnosticFact[] = []; + let seen = 0; + let catalog = 0; + let complete = true; + for (const row of rows) { + catalog += 1; + if (catalog > 512) configurationError('diagnostic table budget is exceeded'); + if ( + typeof row.name !== 'string' || + typeof row.type !== 'string' || + Buffer.byteLength(row.name, 'utf8') > MAX_NAME_BYTES + ) { + configurationError('diagnostic table catalog drifted'); + } + if (classifyLocalReconciliationFact(database, row.name) !== domain) continue; + const ordinal = seen + 1; + seen += 1; + if (seen <= offset) continue; + if (records.length >= limit) { + complete = false; + break; + } + records.push( + fact( + ordinal, + database, + domain, + 'table', + row.type, + row.name, + row.name, + rowCount(client, domain, row.name, row.type), + ), + ); + } + return Object.freeze({ records: Object.freeze(records), complete }); +} + +export function buildLocalReconciliationDiagnosticPage( + client: DatabaseSync, + options: Readonly, +): Readonly { + const request = options.command.request; + const selected = + request.factKind === 'schema_object' + ? schemaFacts( + client, + request.database, + request.domain, + request.offset, + request.limit, + ) + : tableFacts( + client, + request.database, + request.domain, + request.offset, + request.limit, + ); + const nextOffset = selected.complete + ? null + : request.offset + selected.records.length; + const payload = Object.freeze({ + schema: PAGE_SCHEMA, + schemaVersion: 1 as const, + state: 'reconciliation_review_prepared' as const, + reviewId: options.reviewId, + planId: options.planId, + planDigest: options.planDigest, + preparationDigest: options.preparationDigest, + bundleDigest: options.bundle.receipt.bundleDigest, + bundleFingerprintDigest: options.bundle.fingerprintDigest, + database: request.database, + domain: request.domain, + factKind: request.factKind, + offset: request.offset, + limit: request.limit, + recordCount: selected.records.length, + complete: selected.complete, + nextOffset, + records: selected.records, + }); + const page = Object.freeze({ ...payload, pageDigest: cutoverDigest(payload) }); + if (Buffer.byteLength(`${JSON.stringify(page, null, 2)}\n`, 'utf8') > MAX_PAGE_BYTES) { + configurationError('diagnostic page exceeds its 256 KiB budget'); + } + return page; +} + +function pageBytes(page: Readonly): Buffer { + const bytes = Buffer.from(`${JSON.stringify(page, null, 2)}\n`, 'utf8'); + if (bytes.byteLength < 2 || bytes.byteLength > MAX_PAGE_BYTES) { + configurationError('diagnostic page has an invalid size'); + } + return bytes; +} + +function pageFile( + filePath: string, + bytes: Buffer, + uid: number, + allowedLinks: readonly number[], + label: string, +): fs.Stats { + let stat: fs.Stats; + try { + stat = fs.lstatSync(filePath); + } catch (error) { + return configurationError(`${label} is unavailable`, error); + } + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.uid !== uid || + (stat.mode & 0o777) !== 0o600 || + !allowedLinks.includes(stat.nlink) || + stat.size !== bytes.byteLength || + !fs.readFileSync(filePath).equals(bytes) + ) { + configurationError(`${label} identity or content drifted`); + } + return stat; +} + +function syncDirectory(directory: string): void { + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function writeStage( + stagePath: string, + bytes: Buffer, + uid: number, +): void { + let descriptor: number | undefined; + let created = false; + try { + descriptor = fs.openSync( + stagePath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + created = true; + fs.fchmodSync(descriptor, 0o600); + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.uid !== uid || stat.nlink !== 1) { + configurationError('diagnostic page stage identity drifted'); + } + let offset = 0; + while (offset < bytes.byteLength) { + const written = fs.writeSync( + descriptor, + bytes, + offset, + bytes.byteLength - offset, + ); + if (written < 1) configurationError('diagnostic page write stalled'); + offset += written; + } + fs.fsyncSync(descriptor); + } catch (error) { + if (created) { + try { + fs.unlinkSync(stagePath); + } catch { + // A failed cleanup leaves a deterministic fail-closed stage. + } + } + if (error instanceof LocalDeploymentConfigurationError) throw error; + configurationError('diagnostic page stage cannot be written', error); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +export function publishLocalReconciliationDiagnosticPage( + outputPath: string, + page: Readonly, + uid: number, +): 'prepared' | 'existing' { + const parent = path.dirname(outputPath); + validatePrivateDirectory(parent, uid, 'diagnosticOutputParent'); + const bytes = pageBytes(page); + const stagePath = path.join( + parent, + `.${path.basename(outputPath)}.ql3-review-page-stage`, + ); + const existed = fs.existsSync(outputPath); + if (existed) pageFile(outputPath, bytes, uid, [1, 2], 'diagnostic page'); + if (fs.existsSync(stagePath)) { + pageFile(stagePath, bytes, uid, [1, 2], 'diagnostic page stage'); + } else if (!existed) { + writeStage(stagePath, bytes, uid); + } + if (!fs.existsSync(outputPath)) { + try { + fs.linkSync(stagePath, outputPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + configurationError('diagnostic page cannot be published', error); + } + } + syncDirectory(parent); + } + const target = pageFile(outputPath, bytes, uid, [1, 2], 'diagnostic page'); + if (fs.existsSync(stagePath)) { + const stage = pageFile( + stagePath, + bytes, + uid, + [1, 2], + 'diagnostic page stage', + ); + if (target.dev !== stage.dev || target.ino !== stage.ino) { + configurationError('diagnostic page stage identity drifted'); + } + fs.unlinkSync(stagePath); + syncDirectory(parent); + } + pageFile(outputPath, bytes, uid, [1], 'diagnostic page'); + return existed ? 'existing' : 'prepared'; +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/preparation.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/preparation.ts new file mode 100644 index 00000000..425db941 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/preparation.ts @@ -0,0 +1,517 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; + +import { currentIdentity } from '../../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../../foundation/error'; +import { + ensurePrivateDirectory, + preflightPublishedFile, + publishExactFile, + validatePrivateDirectory, +} from '../../foundation/files'; +import { + advanceLocalCutoverInstanceHead, + readLocalCutoverInstanceHead, + type LocalCutoverInstanceHead, +} from '../../cutover/instanceLineage'; +import { cutoverDigest } from '../../cutover/targetEvidence'; +import { readLocalReconciliationPlanTerminal } from '../planning/preparation'; +import { + withLocalReconciliationSealedDatabase, + type LocalReconciliationSealedBundleReaderDependencies, +} from '../sealed-bundle/reader'; +import { + normalizeLocalReconciliationReviewDiagnosticsCommand, + normalizeLocalReconciliationReviewPrepareCommand, + type LocalReconciliationReviewDiagnosticsCommand, + type LocalReconciliationReviewDiagnosticsResult, + type LocalReconciliationReviewPrepareCommand, + type LocalReconciliationReviewPrepareResult, +} from './contract'; +import { + buildLocalReconciliationDiagnosticPage, + publishLocalReconciliationDiagnosticPage, +} from './diagnostics'; + +const INTENT_SCHEMA = 'qinglong3-local-reconciliation-review-intent'; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const MAX_REVIEWS = 64; + +export interface LocalReconciliationReviewIntent { + readonly schema: typeof INTENT_SCHEMA; + readonly schemaVersion: 1; + readonly state: 'reconciliation_review_prepared'; + readonly command: Readonly; + readonly profile: 'edge' | 'standalone'; + readonly instanceId: string; + readonly cutoverId: string; + readonly generation: number; + readonly activationDigest: string; + readonly bundleDigest: string; + readonly bundleFingerprintDigest: string; + readonly planReceiptDigest: string; + readonly plannedHeadDigest: string; + readonly preparationDigest: string; +} + +export interface LocalReconciliationReviewDependencies + extends LocalReconciliationSealedBundleReaderDependencies { + readonly afterHeadPrepared?: () => void; + readonly beforeDiagnosticPublish?: () => void; + readonly afterDiagnosticPublish?: () => void; +} + +interface LocalReconciliationReviewPaths { + readonly root: string; + readonly staging: string; + readonly intent: string; +} + +function configurationError(message: string): never { + throw new LocalDeploymentConfigurationError(message); +} + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function contents(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function overlaps(left: string, right: string): boolean { + const relative = path.relative(left, right); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +export function localReconciliationReviewDirectory( + reviewRoot: string, + reviewId: string, +): string { + return path.join(reviewRoot, reviewId); +} + +function reviewPaths( + reviewRoot: string, + reviewId: string, +): Readonly { + const root = localReconciliationReviewDirectory(reviewRoot, reviewId); + return Object.freeze({ + root, + staging: path.join(root, 'staging'), + intent: path.join(root, 'intent.json'), + }); +} + +function ensureReviewDirectory( + reviewRoot: string, + reviewId: string, + uid: number, +): Readonly { + const paths = reviewPaths(reviewRoot, reviewId); + const entries = fs.readdirSync(reviewRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + configurationError('reconciliation review catalog contains drift'); + } + } + if (entries.length >= MAX_REVIEWS && !fs.existsSync(paths.root)) { + configurationError('reconciliation review retention limit is reached'); + } + ensurePrivateDirectory(paths.root, uid, 'reconciliationReviewDirectory'); + ensurePrivateDirectory(paths.staging, uid, 'reconciliationReviewStaging'); + return paths; +} + +function validateCatalog(paths: Readonly): void { + const allowed = new Set([ + 'intent.json', + 'staging', + '.intent.json.ql3-deploy-stage', + ]); + for (const entry of fs.readdirSync(paths.root, { withFileTypes: true })) { + if (!allowed.has(entry.name) || entry.isSymbolicLink()) { + configurationError('reconciliation review root contains unknown material'); + } + } + if (fs.readdirSync(paths.staging).length !== 0) { + configurationError('reconciliation review staging contains unknown material'); + } +} + +function buildIntent( + command: Readonly, + terminal: ReturnType, +): Readonly { + if ( + terminal.intent.command.options.deploymentRoot !== + command.options.deploymentRoot || + terminal.intent.command.options.captureRoot !== command.options.captureRoot || + terminal.intent.command.options.planRoot !== command.options.planRoot || + terminal.intent.command.options.allowRootService !== + command.options.allowRootService || + terminal.plan.planId !== command.request.planId || + terminal.plan.planDigest !== command.request.expectedPlanDigest || + command.request.preparedAtMs < terminal.plan.committedAtMs + ) { + configurationError('reconciliation review is detached from its plan'); + } + const payload = Object.freeze({ + schema: INTENT_SCHEMA, + schemaVersion: 1 as const, + state: 'reconciliation_review_prepared' as const, + command, + profile: terminal.plan.profile, + instanceId: terminal.intent.instanceId, + cutoverId: terminal.intent.cutoverId, + generation: terminal.intent.generation, + activationDigest: terminal.intent.activationDigest, + bundleDigest: terminal.plan.bundleDigest, + bundleFingerprintDigest: terminal.bundle.fingerprintDigest, + planReceiptDigest: terminal.receipt.receiptDigest, + plannedHeadDigest: command.request.expectedHeadDigest, + }); + return Object.freeze({ ...payload, preparationDigest: cutoverDigest(payload) }); +} + +export function normalizeLocalReconciliationReviewIntent( + value: unknown, +): Readonly { + const intent = object(value, 'reconciliation review intent'); + exact( + intent, + [ + 'activationDigest', + 'bundleDigest', + 'bundleFingerprintDigest', + 'command', + 'cutoverId', + 'generation', + 'instanceId', + 'planReceiptDigest', + 'plannedHeadDigest', + 'preparationDigest', + 'profile', + 'schema', + 'schemaVersion', + 'state', + ], + 'reconciliation review intent', + ); + const command = normalizeLocalReconciliationReviewPrepareCommand(intent.command); + const { preparationDigest, ...payload } = intent; + if ( + intent.schema !== INTENT_SCHEMA || + intent.schemaVersion !== 1 || + intent.state !== 'reconciliation_review_prepared' || + (intent.profile !== 'edge' && intent.profile !== 'standalone') || + typeof intent.instanceId !== 'string' || + intent.instanceId.length < 1 || + intent.instanceId.length > 128 || + typeof intent.cutoverId !== 'string' || + intent.cutoverId.length < 1 || + intent.cutoverId.length > 128 || + !Number.isSafeInteger(intent.generation) || + (intent.generation as number) < 1 || + [ + intent.activationDigest, + intent.bundleDigest, + intent.bundleFingerprintDigest, + intent.planReceiptDigest, + intent.plannedHeadDigest, + preparationDigest, + ].some( + (candidate) => + typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate), + ) || + cutoverDigest(payload) !== preparationDigest + ) { + configurationError('reconciliation review intent drifted'); + } + return Object.freeze({ + ...(intent as unknown as LocalReconciliationReviewIntent), + command, + }); +} + +export function readLocalReconciliationReviewIntent( + reviewRoot: string, + reviewId: string, +): Readonly { + return normalizeLocalReconciliationReviewIntent( + readPrivateLocalCommandFile(reviewPaths(reviewRoot, reviewId).intent), + ); +} + +function validateHeadIdentity( + head: Readonly, + intent: Readonly, +): void { + if ( + head.profile !== intent.profile || + head.cutoverId !== intent.cutoverId || + head.activationDigest !== intent.activationDigest || + head.generation !== intent.generation + ) { + configurationError('reconciliation review instance head identity drifted'); + } +} + +function advancePreparedHead( + intent: Readonly, + uid: number, +): Readonly { + return advanceLocalCutoverInstanceHead( + { + options: { deploymentRoot: intent.command.options.deploymentRoot }, + request: { + cutoverId: intent.cutoverId, + profile: intent.profile, + instanceId: intent.instanceId, + expectedActivationDigest: intent.activationDigest, + requestedAtMs: intent.command.request.preparedAtMs, + }, + }, + uid, + 'reconciliation_review_prepared', + intent.generation, + intent.preparationDigest, + ); +} + +export function prepareLocalReconciliationReview( + input: unknown, + dependencies: LocalReconciliationReviewDependencies = {}, +): Readonly { + const command = normalizeLocalReconciliationReviewPrepareCommand(input); + const identity = currentIdentity(); + validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot'); + validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot'); + validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot'); + validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot'); + const terminal = readLocalReconciliationPlanTerminal( + command.options.planRoot, + command.request.planId, + identity.uid, + ); + const intent = buildIntent(command, terminal); + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + identity.uid, + ); + validateHeadIdentity(head, intent); + if ( + (head.state === 'reconciliation_planned' && + (head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== command.request.expectedPlanDigest)) || + (head.state === 'reconciliation_review_prepared' && + head.sourceRecordDigest !== intent.preparationDigest) || + (head.state !== 'reconciliation_planned' && + head.state !== 'reconciliation_review_prepared') + ) { + configurationError('review prepare lost the planned instance head compare-and-swap'); + } + const paths = ensureReviewDirectory( + command.options.reviewRoot, + command.request.reviewId, + identity.uid, + ); + const serialized = contents(intent); + preflightPublishedFile( + paths.intent, + serialized, + 0o600, + identity.uid, + 'reconciliation review intent', + ); + const nextHead = + head.state === 'reconciliation_review_prepared' + ? head + : advancePreparedHead(intent, identity.uid); + dependencies.afterHeadPrepared?.(); + const status = publishExactFile( + paths.intent, + serialized, + 0o600, + identity.uid, + 'reconciliation review intent', + ); + validateCatalog(paths); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + status, + state: 'reconciliation_review_prepared' as const, + reviewId: command.request.reviewId, + preparationDigest: intent.preparationDigest, + instanceHeadDigest: nextHead.headDigest, + }); +} + +function validateDiagnosticsBinding( + command: Readonly, + intent: Readonly, +): void { + if ( + intent.command.options.deploymentRoot !== command.options.deploymentRoot || + intent.command.options.captureRoot !== command.options.captureRoot || + intent.command.options.planRoot !== command.options.planRoot || + intent.command.options.reviewRoot !== command.options.reviewRoot || + intent.command.options.allowRootService !== command.options.allowRootService || + intent.command.request.reviewId !== command.request.reviewId || + intent.preparationDigest !== command.request.expectedPreparationDigest + ) { + configurationError('review diagnostics are detached from preparation'); + } + const roots = [ + command.options.deploymentRoot, + command.options.captureRoot, + command.options.planRoot, + command.options.reviewRoot, + ]; + if ( + roots.some( + (root) => + overlaps(root, command.request.outputPath) || + overlaps(command.request.outputPath, root), + ) + ) { + configurationError('diagnostic output must be outside authority roots'); + } +} + +export function writeLocalReconciliationReviewDiagnostics( + input: unknown, + dependencies: LocalReconciliationReviewDependencies = {}, +): Readonly { + const command = normalizeLocalReconciliationReviewDiagnosticsCommand(input); + const identity = currentIdentity(); + validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot'); + validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot'); + validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot'); + validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot'); + const paths = reviewPaths(command.options.reviewRoot, command.request.reviewId); + validatePrivateDirectory(paths.root, identity.uid, 'reconciliationReviewDirectory'); + validatePrivateDirectory(paths.staging, identity.uid, 'reconciliationReviewStaging'); + const intent = readLocalReconciliationReviewIntent( + command.options.reviewRoot, + command.request.reviewId, + ); + validateDiagnosticsBinding(command, intent); + const terminal = readLocalReconciliationPlanTerminal( + command.options.planRoot, + intent.command.request.planId, + identity.uid, + ); + if ( + terminal.plan.planDigest !== intent.command.request.expectedPlanDigest || + terminal.plan.bundleDigest !== intent.bundleDigest || + terminal.receipt.receiptDigest !== intent.planReceiptDigest || + terminal.bundle.fingerprintDigest !== intent.bundleFingerprintDigest + ) { + configurationError('review diagnostics lost the terminal plan binding'); + } + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + identity.uid, + ); + validateHeadIdentity(head, intent); + if ( + head.state !== 'reconciliation_review_prepared' || + head.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError('review diagnostics lost the prepared instance head'); + } + const page = withLocalReconciliationSealedDatabase( + terminal.bundle, + command.request.database, + identity.uid, + dependencies, + (client) => + buildLocalReconciliationDiagnosticPage(client, { + reviewId: intent.command.request.reviewId, + planId: intent.command.request.planId, + planDigest: terminal.plan.planDigest, + preparationDigest: intent.preparationDigest, + bundle: terminal.bundle, + command, + }), + ); + if (page === null) { + configurationError('manual-required SQLite topology has no diagnostic page'); + } + const currentHead = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + identity.uid, + ); + if ( + currentHead.headDigest !== head.headDigest || + currentHead.state !== 'reconciliation_review_prepared' || + currentHead.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError('review diagnostics instance head changed while reading'); + } + dependencies.beforeDiagnosticPublish?.(); + const status = publishLocalReconciliationDiagnosticPage( + command.request.outputPath, + page, + identity.uid, + ); + dependencies.afterDiagnosticPublish?.(); + validateCatalog(paths); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + status, + state: 'reconciliation_review_prepared' as const, + reviewId: command.request.reviewId, + pageDigest: page.pageDigest, + recordCount: page.recordCount, + complete: page.complete, + nextOffset: page.nextOffset, + instanceHeadDigest: currentHead.headDigest, + }); +} + +export function prepareLocalReconciliationReviewCommandFile( + filePath: string, +): Readonly { + return prepareLocalReconciliationReview(readPrivateLocalCommandFile(filePath)); +} + +export function writeLocalReconciliationReviewDiagnosticsCommandFile( + filePath: string, +): Readonly { + return writeLocalReconciliationReviewDiagnostics( + readPrivateLocalCommandFile(filePath), + ); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts index a12a7dfe..6e0d48a8 100644 --- a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts +++ b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts @@ -751,7 +751,9 @@ function replayResult( (head.state === 'reconciliation_capture_prepared' || head.state === 'reconciliation_captured' || head.state === 'reconciliation_plan_prepared' || - head.state === 'reconciliation_planned'); + head.state === 'reconciliation_planned' || + head.state === 'reconciliation_review_prepared' || + head.state === 'reconciliation_reviewed'); if ( record.actionId !== intent.actionId || record.intentDigest !== intent.intentDigest || diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index c21114fb..a564cd7b 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -12,8 +12,10 @@ const { commitLocalReconciliationPlan, prepareLocalReconciliationCapture, prepareLocalReconciliationPlan, + prepareLocalReconciliationReview, verifyLocalReconciliationCapture, verifyLocalReconciliationPlan, + writeLocalReconciliationReviewDiagnostics, } = require('../dist/deployment/localDeployment.js'); const { normalizeLocalReconciliationCaptureManifest, @@ -688,6 +690,74 @@ function preparedPlan(t, options = {}) { }; } +function preparedReview(t, options = {}) { + const state = preparedPlan(t, options); + const planned = commitLocalReconciliationPlan(state.planCommitCommand); + const root = path.dirname(state.captureRoot); + const reviewRoot = path.join(root, `review-root-${options.reviewSuffix ?? '1'}`); + const diagnosticRoot = path.join( + root, + `diagnostic-root-${options.reviewSuffix ?? '1'}`, + ); + fs.mkdirSync(reviewRoot, { mode: 0o700 }); + fs.mkdirSync(diagnosticRoot, { mode: 0o700 }); + const reviewCommand = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.review.prepare', + options: { + deploymentRoot: state.deploymentRoot, + captureRoot: state.captureRoot, + planRoot: state.planRoot, + reviewRoot, + allowRootService: rootAcknowledgement(), + }, + request: { + reviewId: + options.reviewId ?? '00000000-0000-4000-8000-000000000301', + planId: state.prepareCommand.request.planId, + expectedPlanDigest: planned.planDigest, + expectedHeadDigest: planned.instanceHeadDigest, + preparedAtMs: 8_000, + }, + }; + return { + ...state, + planned, + reviewRoot, + diagnosticRoot, + reviewCommand, + }; +} + +function diagnosticCommand( + state, + prepared, + { + database = 'legacy', + domain = 'automation', + factKind = 'table', + offset = 0, + limit = 64, + outputName = 'diagnostic-page.json', + } = {}, +) { + return { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.review.diagnostics', + options: state.reviewCommand.options, + request: { + reviewId: state.reviewCommand.request.reviewId, + expectedPreparationDigest: prepared.preparationDigest, + database, + domain, + factKind, + offset, + limit, + outputPath: path.join(state.diagnosticRoot, outputName), + }, + }; +} + function dockerReadSealedSqlite(assetsDirectory, mode) { const source = mode === 'main_only_immutable' @@ -1547,6 +1617,234 @@ test('plan prepare recovers head response loss and CLI verify stays content-free ); }); +test('review prepare establishes one replayable fence and blocks rollback', (t) => { + const state = preparedReview(t, { + planId: '00000000-0000-4000-8000-000000000301', + reviewId: '00000000-0000-4000-8000-000000000311', + }); + assert.throws( + () => + prepareLocalReconciliationReview(state.reviewCommand, { + afterHeadPrepared() { + throw new Error('review prepare response loss'); + }, + }), + /review prepare response loss/, + ); + const prepared = prepareLocalReconciliationReview(state.reviewCommand); + assert.equal(prepared.status, 'prepared'); + assert.equal(prepared.state, 'reconciliation_review_prepared'); + const replay = prepareLocalReconciliationReview(state.reviewCommand); + assert.equal(replay.status, 'existing'); + assert.equal(replay.preparationDigest, prepared.preparationDigest); + assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest); + const head = readLocalCutoverInstanceHead( + state.deploymentRoot, + state.command.request.instanceId, + state.uid, + ); + assert.equal(head.state, 'reconciliation_review_prepared'); + assert.equal(head.sourceRecordDigest, prepared.preparationDigest); + + const competing = structuredClone(state.reviewCommand); + competing.request.reviewId = '00000000-0000-4000-8000-000000000312'; + competing.options.reviewRoot = path.join( + path.dirname(state.reviewRoot), + 'review-root-competing', + ); + fs.mkdirSync(competing.options.reviewRoot, { mode: 0o700 }); + assert.throws( + () => prepareLocalReconciliationReview(competing), + /compare-and-swap/, + ); + assert.throws( + () => + advanceLocalCutoverInstanceHead( + state.identity, + state.uid, + 'rollback_prepared', + 1, + 'f'.repeat(64), + ), + /transition is invalid/, + ); + assert.throws( + () => + advanceLocalCutoverInstanceHead( + state.identity, + state.uid, + 'target_active', + 2, + 'e'.repeat(64), + ), + /transition is invalid/, + ); +}); + +test('review diagnostics publish one private exact page without changing assets', (t) => { + const state = preparedReview(t, { + planId: '00000000-0000-4000-8000-000000000321', + reviewId: '00000000-0000-4000-8000-000000000322', + }); + const prepared = prepareLocalReconciliationReview(state.reviewCommand); + const assets = fs.readdirSync(capturePath(state, 'assets')).map((name) => ({ + name, + bytes: fs.readFileSync(capturePath(state, `assets/${name}`)), + stat: fs.statSync(capturePath(state, `assets/${name}`), { bigint: true }), + })); + const command = diagnosticCommand(state, prepared); + const opens = []; + const result = writeLocalReconciliationReviewDiagnostics(command, { + beforeDatabaseOpen(kind, mode, cacheKiB) { + opens.push({ kind, mode, cacheKiB }); + }, + }); + assert.deepEqual(opens, [ + { kind: 'legacy', mode: 'main_only_immutable', cacheKiB: 2048 }, + ]); + assert.equal(result.status, 'prepared'); + assert.equal(result.recordCount, 1); + assert.equal(result.complete, true); + assert.equal(result.nextOffset, null); + assert.equal(JSON.stringify(result).includes(command.request.outputPath), false); + assert.equal(JSON.stringify(result).includes('Crontabs'), false); + const pageText = fs.readFileSync(command.request.outputPath, 'utf8'); + const page = JSON.parse(pageText); + assert.equal(page.records[0].name, 'Crontabs'); + assert.equal(page.records[0].rowCount, '1'); + assert.equal(page.records[0].decisionRequirement, 'required'); + assert.equal(pageText.includes('0 0 * * *'), false); + assert.equal(pageText.includes('private-value'), false); + assert.equal(fs.statSync(command.request.outputPath).mode & 0o777, 0o600); + assert.equal( + writeLocalReconciliationReviewDiagnostics(command).status, + 'existing', + ); + for (const before of assets) { + const assetPath = capturePath(state, `assets/${before.name}`); + const after = fs.statSync(assetPath, { bigint: true }); + assert.equal(fs.readFileSync(assetPath).equals(before.bytes), true); + assert.equal(after.mtimeNs, before.stat.mtimeNs); + assert.equal(after.ctimeNs, before.stat.ctimeNs); + assert.equal(after.mode, before.stat.mode); + } +}); + +test('review diagnostics keep secret and unknown facts blocked and row-free', (t) => { + const state = preparedReview(t, { + unknownTargetTable: true, + planId: '00000000-0000-4000-8000-000000000331', + reviewId: '00000000-0000-4000-8000-000000000332', + }); + const prepared = prepareLocalReconciliationReview(state.reviewCommand); + const secretCommand = diagnosticCommand(state, prepared, { + domain: 'secret_and_config', + outputName: 'secret.json', + }); + writeLocalReconciliationReviewDiagnostics(secretCommand); + const secretText = fs.readFileSync(secretCommand.request.outputPath, 'utf8'); + const secret = JSON.parse(secretText); + assert.equal(secret.records[0].name, 'Envs'); + assert.equal(secret.records[0].decisionRequirement, 'blocked'); + assert.equal(secret.records[0].reason, 'secret_custody_required'); + assert.equal(secretText.includes('TOKEN'), false); + assert.equal(secretText.includes('private-value'), false); + + const unknownCommand = diagnosticCommand(state, prepared, { + database: 'target', + domain: 'unknown', + outputName: 'unknown.json', + }); + writeLocalReconciliationReviewDiagnostics(unknownCommand); + const unknown = JSON.parse( + fs.readFileSync(unknownCommand.request.outputPath, 'utf8'), + ); + assert.equal(unknown.records[0].name, 'UnreviewedFacts'); + assert.equal(unknown.records[0].rowCount, null); + assert.equal(unknown.records[0].decisionRequirement, 'blocked'); + assert.equal(unknown.records[0].reason, 'unknown_schema'); + + const overlapping = structuredClone(unknownCommand); + overlapping.request.outputPath = path.join(state.reviewRoot, 'leak.json'); + assert.throws( + () => writeLocalReconciliationReviewDiagnostics(overlapping), + /outside authority roots/, + ); +}); + +test('review diagnostics page at sixty-four and CLI output stays content-free', (t) => { + const initializeDatabases = (paths) => { + planningDatabaseInitializer()(paths); + const target = new DatabaseSync(paths.targetDatabasePath); + for (let index = 0; index < 70; index += 1) { + target.exec( + `CREATE TABLE "QingLong3TaskDefinitionExtra${String(index).padStart( + 2, + '0', + )}" (id INTEGER PRIMARY KEY)`, + ); + } + target.close(); + fs.chmodSync(paths.targetDatabasePath, 0o600); + }; + const state = preparedReview(t, { + initializeDatabases, + planId: '00000000-0000-4000-8000-000000000341', + reviewId: '00000000-0000-4000-8000-000000000342', + }); + const prepared = prepareLocalReconciliationReview(state.reviewCommand); + const firstCommand = diagnosticCommand(state, prepared, { + database: 'target', + domain: 'automation', + factKind: 'schema_object', + outputName: 'page-1.json', + }); + const first = writeLocalReconciliationReviewDiagnostics(firstCommand); + assert.equal(first.recordCount, 64); + assert.equal(first.complete, false); + assert.equal(first.nextOffset, 64); + const secondCommand = diagnosticCommand(state, prepared, { + database: 'target', + domain: 'automation', + factKind: 'schema_object', + offset: first.nextOffset, + outputName: 'page-2.json', + }); + const second = writeLocalReconciliationReviewDiagnostics(secondCommand); + assert.equal(second.recordCount, 7); + assert.equal(second.complete, true); + assert.equal(second.nextOffset, null); + + const cliCommand = diagnosticCommand(state, prepared, { + database: 'target', + domain: 'automation', + factKind: 'table', + limit: 1, + outputName: 'cli-page.json', + }); + const commandPath = path.join(state.deploymentRoot, 'review-diagnostic.json'); + fs.writeFileSync(commandPath, `${JSON.stringify(cliCommand)}\n`, { + mode: 0o600, + }); + const cli = spawnSync( + process.execPath, + [ + path.join(__dirname, '../dist/deployment/localDeploymentCli.js'), + 'reconciliation-review-diagnostics', + '--command-file', + commandPath, + ], + { encoding: 'utf8' }, + ); + assert.equal(cli.status, 0, cli.stderr); + const output = JSON.parse(cli.stdout); + assert.equal(output.recordCount, 1); + assert.equal(cli.stdout.includes(state.reviewRoot), false); + assert.equal(cli.stdout.includes(state.diagnosticRoot), false); + assert.equal(cli.stdout.includes('QingLong3TaskDefinitions'), false); + assert.equal(cli.stderr, ''); +}); + test( 'real stopped Docker target produces an independently verified bundle', { skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' }, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index d35536d0..c62b41f9 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localOwnerCli.rootSourceFileRoles, }, { - sourceFiles: 146, + sourceFiles: 149, rootSourceFiles: 1, rootSourceLines: 50, - nestedSourceFiles: 145, + nestedSourceFiles: 148, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );