feat(local): recover reconciliation automation apply

This commit is contained in:
whyour
2026-08-22 20:41:54 +08:00
parent 2af3ccc158
commit 546b02a0bc
10 changed files with 2181 additions and 45 deletions
+24
View File
@@ -55,6 +55,30 @@
signed decision,完成 target 写前可恢复 backup 证明、Project Policy/credential 复验、幂等原子 transaction/replay、post-apply evidence 与显式
rollback。当前状态仍不代表 applied、restart-ready 或 reconciliation-complete。进入 apply 写语义时必须重新选择 SQLite/Docker 数据门禁,不能
复用本切片只读证明。
第三切片已消费上述 signed decision 并建立首个真实写入闭环,而没有新增第二套 Task/Trigger DML。Local Admin 的 plan-bound verifier 现在只把
已验证的 decision scope 交给既有 `LocalSqliteLegacyAdoptionPublisher`;后者继续以 `BEGIN IMMEDIATE`、Project/RoleBinding version fence、
mutation/decision exact replay、Task/Trigger collision 检查、security audit 与 adoption ledger 作为唯一写入 authority。Local Owner 新增
`reconciliation-automation-apply|apply-verify|apply-rollback`,实例 lineage 严格前进为
`reconciliation_automation_reviewed → reconciliation_automation_apply_prepared → reconciliation_automation_applied →
reconciliation_automation_rolled_back`。apply 在 DML 前重新证明原 target 仍处于同一 stopped authority,直接复用 `local-sqlite/rollout-safety`
的 online SQLite backup、readiness/snapshot audit 和 no-replace stage/link publicationbackup、intent 与 prepared head 完成后,再重新打开 sealed
Legacy bundle、逐行 plan 和 `0400/0500` signed authorization,并把最多 5 分钟的当前强认证 User 与原 reviewer 精确匹配。事务提交前还会复验
prepared head、sealed bundle、authorization inode、当前 credential 与 Project Policy fence。database commit、receipt、head 任一 response-loss
窗口通过相同 mutation/decision ledger 收敛,不能重复插入 Task/Trigger。
apply receipt 绑定 publication digest、adopted Task/Trigger/skipped counters、写后完整 SQLite snapshot 和写前 backupverify 同时检查 signed
decision、instance head 与当前 target snapshotstdout 不返回 reviewer、路径、row digest 或 command。rollback 不是裸文件覆盖:它先以当前 reviewer
强认证和当前 Project Policy 重放已存在 publication,再要求 target 精确等于 apply snapshot,最后调用 `preserveDatabaseIdentity=true` 的 restore
保持 activation 所绑定的 SQLite inode。restore、rollback receipt 和 rollback head 的 response-loss 同样可恢复;可恢复 backup authority 因仍需支持
apply 后显式 rollback,保持 current-UID `0700/0600`,不伪装成已经归档的 `0500/0400` terminal。聚焦 reconciliation 套件仍为
`44 total / 42 pass / 2 conditional Docker skip / 0 fail`,但成功场景现在额外覆盖首次 apply、mutation replay、不同 reviewer 拒绝、六个
backup/apply/rollback response-loss 窗口、content-free CLI verify、identity-preserving rollback 与 rollback replay。完整 Local Owner 在受限沙箱为
`266 total / 256 pass / 7 conditional skip / 3 loopback-listen EPERM`,两个对应文件在沙箱外 `15/15` 通过;package/dependency boundary 为
`69/69`。workspace 仍为 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`Local Owner 为
`168 source / 167 nested / 1 root binary entry`,没有新增 package、production dependency、daemon、listener、timer 或 `src/` 根平铺。
D-393 仍未代表完整 reconciliationAutomation 之外的 Secret、Plugin、Identity、history 等领域 adapter 尚未 applytarget service 也未获得 restart
authority;后续必须先完成 apply-root 的 retention/seal 策略和跨领域 completion fence,再进入 target restart/readiness。
- D-392/ADR-0485(已接受):D-391 的 signed review 不能直接获得通用 DML authority;表级 `adopt_legacy/retain_both` 也不能证明
Automation 行级 command/trigger 兼容,更不能覆盖 Secret custody、append-only history、Plugin/AI 外部资产与 Identity/Policy 语义。
因此既有 Local Owner 新增 `reconciliation.application.prepare|commit|verify`,以
@@ -34,6 +34,9 @@ export type LocalCutoverInstanceHeadState =
| 'reconciliation_automation_planned'
| 'reconciliation_automation_decision_prepared'
| 'reconciliation_automation_reviewed'
| 'reconciliation_automation_apply_prepared'
| 'reconciliation_automation_applied'
| 'reconciliation_automation_rolled_back'
| 'rollback_prepared'
| 'legacy_restart_requested'
| 'legacy_running'
@@ -176,6 +179,9 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
head.state !== 'reconciliation_automation_planned' &&
head.state !== 'reconciliation_automation_decision_prepared' &&
head.state !== 'reconciliation_automation_reviewed' &&
head.state !== 'reconciliation_automation_apply_prepared' &&
head.state !== 'reconciliation_automation_applied' &&
head.state !== 'reconciliation_automation_rolled_back' &&
head.state !== 'rollback_prepared' &&
head.state !== 'legacy_restart_requested' &&
head.state !== 'legacy_running' &&
@@ -353,6 +359,9 @@ export function advanceLocalCutoverInstanceHead(
| 'reconciliation_automation_planned'
| 'reconciliation_automation_decision_prepared'
| 'reconciliation_automation_reviewed'
| 'reconciliation_automation_apply_prepared'
| 'reconciliation_automation_applied'
| 'reconciliation_automation_rolled_back'
| 'rollback_prepared'
| 'legacy_restart_requested'
| 'legacy_running'
@@ -402,6 +411,9 @@ export function advanceLocalCutoverInstanceHead(
current.state === 'reconciliation_automation_planned' ||
current.state === 'reconciliation_automation_decision_prepared' ||
current.state === 'reconciliation_automation_reviewed' ||
current.state === 'reconciliation_automation_apply_prepared' ||
current.state === 'reconciliation_automation_applied' ||
current.state === 'reconciliation_automation_rolled_back' ||
current.state === 'legacy_restart_requested' ||
current.state === 'legacy_running' ||
current.state === 'legacy_ready')
@@ -439,6 +451,12 @@ export function advanceLocalCutoverInstanceHead(
current.state === 'reconciliation_automation_planned') ||
(state === 'reconciliation_automation_reviewed' &&
current.state === 'reconciliation_automation_decision_prepared') ||
(state === 'reconciliation_automation_apply_prepared' &&
current.state === 'reconciliation_automation_reviewed') ||
(state === 'reconciliation_automation_applied' &&
current.state === 'reconciliation_automation_apply_prepared') ||
(state === 'reconciliation_automation_rolled_back' &&
current.state === 'reconciliation_automation_applied') ||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
(state === 'legacy_restart_requested' &&
current.state === 'rollback_prepared') ||
@@ -142,9 +142,18 @@ import {
commitLocalReconciliationAutomationDecisionCommandFile,
prepareLocalReconciliationAutomationDecision,
prepareLocalReconciliationAutomationDecisionCommandFile,
readLocalReconciliationAutomationDecisionTerminal,
verifyLocalReconciliationAutomationDecision,
verifyLocalReconciliationAutomationDecisionCommandFile,
} from './reconciliation/application/automation/decisionCoordinator';
import {
applyLocalReconciliationAutomation,
applyLocalReconciliationAutomationCommandFile,
rollbackLocalReconciliationAutomationApply,
rollbackLocalReconciliationAutomationApplyCommandFile,
verifyLocalReconciliationAutomationApply,
verifyLocalReconciliationAutomationApplyCommandFile,
} from './reconciliation/application/automation/applyCoordinator';
export {
commitLocalReconciliationPlan,
@@ -173,12 +182,31 @@ export {
verifyLocalReconciliationAutomationPlanCommandFile,
prepareLocalReconciliationAutomationDecision,
prepareLocalReconciliationAutomationDecisionCommandFile,
readLocalReconciliationAutomationDecisionTerminal,
commitLocalReconciliationAutomationDecision,
commitLocalReconciliationAutomationDecisionCommandFile,
verifyLocalReconciliationAutomationDecision,
verifyLocalReconciliationAutomationDecisionCommandFile,
applyLocalReconciliationAutomation,
applyLocalReconciliationAutomationCommandFile,
verifyLocalReconciliationAutomationApply,
verifyLocalReconciliationAutomationApplyCommandFile,
rollbackLocalReconciliationAutomationApply,
rollbackLocalReconciliationAutomationApplyCommandFile,
};
export {
normalizeLocalReconciliationAutomationApplyCommand,
normalizeLocalReconciliationAutomationApplyRollbackCommand,
normalizeLocalReconciliationAutomationApplyVerifyCommand,
type LocalReconciliationAutomationApplyCommand,
type LocalReconciliationAutomationApplyOptions,
type LocalReconciliationAutomationApplyResult,
type LocalReconciliationAutomationApplyRollbackCommand,
type LocalReconciliationAutomationApplyVerifyCommand,
} from './reconciliation/application/automation/applyContract';
export { type LocalReconciliationAutomationApplyDependencies } from './reconciliation/application/automation/applyCoordinator';
export {
normalizeLocalReconciliationApplicationCommitCommand,
normalizeLocalReconciliationApplicationPrepareCommand,
@@ -214,9 +242,7 @@ export {
type LocalReconciliationAutomationPlanResult,
type LocalReconciliationAutomationVerifyCommand,
} from './reconciliation/application/automation/contract';
export {
type LocalReconciliationAutomationPlanDependencies,
} from './reconciliation/application/automation/coordinator';
export { type LocalReconciliationAutomationPlanDependencies } from './reconciliation/application/automation/coordinator';
export {
normalizeLocalReconciliationAutomationDecisionCommitCommand,
normalizeLocalReconciliationAutomationDecisionPrepareCommand,
@@ -229,9 +255,7 @@ export {
type LocalReconciliationAutomationDecisionTerminalResult,
type LocalReconciliationAutomationDecisionVerifyCommand,
} from './reconciliation/application/automation/decisionContract';
export {
type LocalReconciliationAutomationDecisionDependencies,
} from './reconciliation/application/automation/decisionCoordinator';
export { type LocalReconciliationAutomationDecisionDependencies } from './reconciliation/application/automation/decisionCoordinator';
export {
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
@@ -30,6 +30,9 @@ import {
prepareLocalReconciliationAutomationDecisionCommandFile,
commitLocalReconciliationAutomationDecisionCommandFile,
verifyLocalReconciliationAutomationDecisionCommandFile,
applyLocalReconciliationAutomationCommandFile,
verifyLocalReconciliationAutomationApplyCommandFile,
rollbackLocalReconciliationAutomationApplyCommandFile,
writeLocalReconciliationReviewDiagnosticsCommandFile,
prepareLocalDeploymentCommandFile,
proveLocalDeploymentLegacyReadinessCommandFile,
@@ -45,7 +48,7 @@ import {
} from './localDeployment';
const USAGE =
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
@@ -92,6 +95,9 @@ async function main(argv: readonly string[]): Promise<void> {
argv[0] !== 'reconciliation-automation-decision-prepare' &&
argv[0] !== 'reconciliation-automation-decision-commit' &&
argv[0] !== 'reconciliation-automation-decision-verify' &&
argv[0] !== 'reconciliation-automation-apply' &&
argv[0] !== 'reconciliation-automation-apply-verify' &&
argv[0] !== 'reconciliation-automation-apply-rollback' &&
argv[0] !== 'compose-revision' &&
argv[0] !== 'compose-preflight' &&
argv[0] !== 'compose-apply' &&
@@ -198,6 +204,12 @@ async function main(argv: readonly string[]): Promise<void> {
? commitLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
: argv[0] === 'reconciliation-automation-decision-verify'
? verifyLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
: argv[0] === 'reconciliation-automation-apply'
? applyLocalReconciliationAutomationCommandFile(argv[2]!)
: argv[0] === 'reconciliation-automation-apply-verify'
? verifyLocalReconciliationAutomationApplyCommandFile(argv[2]!)
: argv[0] === 'reconciliation-automation-apply-rollback'
? rollbackLocalReconciliationAutomationApplyCommandFile(argv[2]!)
: argv[0] === 'compose-revision'
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
: argv[0] === 'compose-preflight'
@@ -0,0 +1,380 @@
import path from 'node:path';
import { currentIdentity } from '../../../foundation/contract';
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
const DIGEST = /^[0-9a-f]{64}$/;
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const UUID_V7 =
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const SAFE_PATH = /^\/[A-Za-z0-9._/@-]+$/;
const SAFE_ID = /^[A-Za-z0-9._:@-]{1,256}$/;
export interface LocalReconciliationAutomationApplyOptions {
readonly deploymentRoot: string;
readonly applicationRoot: string;
readonly automationRoot: string;
readonly automationDecisionRoot: string;
readonly automationApplyRoot: string;
readonly targetDatabasePath: string;
readonly ownerPepperKeyringDirectory: string;
readonly credentialFilePath: string;
readonly allowRootService: boolean;
readonly busyTimeoutMs?: number;
}
export interface LocalReconciliationAutomationApplyCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.automation.apply';
readonly options: Readonly<LocalReconciliationAutomationApplyOptions>;
readonly request: Readonly<{
decisionId: string;
automationId: string;
expectedDecisionDigest: string;
expectedHeadDigest: string;
mutationId: string;
requestId: string;
appliedAtMs: number;
}>;
}
export interface LocalReconciliationAutomationApplyVerifyCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.automation.apply.verify';
readonly options: Readonly<LocalReconciliationAutomationApplyOptions>;
readonly request: Readonly<{
decisionId: string;
automationId: string;
expectedApplyDigest: string;
}>;
}
export interface LocalReconciliationAutomationApplyRollbackCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.automation.apply.rollback';
readonly options: Readonly<LocalReconciliationAutomationApplyOptions>;
readonly request: Readonly<{
decisionId: string;
automationId: string;
expectedApplyDigest: string;
expectedHeadDigest: string;
rolledBackAtMs: number;
}>;
}
export interface LocalReconciliationAutomationApplyResult {
readonly schemaVersion: 1;
readonly operation:
| LocalReconciliationAutomationApplyCommand['operation']
| LocalReconciliationAutomationApplyVerifyCommand['operation']
| LocalReconciliationAutomationApplyRollbackCommand['operation'];
readonly status: 'applied' | 'existing' | 'verified' | 'rolled_back';
readonly state:
| 'reconciliation_automation_applied'
| 'reconciliation_automation_rolled_back';
readonly decisionId: string;
readonly automationId: string;
readonly applyDigest: string;
readonly publicationDigest: string;
readonly adoptedTaskCount: number;
readonly adoptedTriggerCount: number;
readonly backupSha256: string;
readonly instanceHeadDigest: string;
}
function fail(message: string): never {
throw new LocalDeploymentConfigurationError(
`reconciliation automation apply ${message}`,
);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
fail(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(
value: Record<string, unknown>,
keys: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, i) => key !== expected[i])
) {
fail(`${label} shape is invalid`);
}
}
function safePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.parse(value).root === value ||
path.normalize(value) !== value ||
value.includes('\0') ||
value.includes('//') ||
!SAFE_PATH.test(value) ||
Buffer.byteLength(value, 'utf8') > 4096
)
fail(`${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 options(
value: unknown,
): Readonly<LocalReconciliationAutomationApplyOptions> {
const selected = record(value, 'options');
const hasBusyTimeout = Object.hasOwn(selected, 'busyTimeoutMs');
exact(
selected,
[
'allowRootService',
'applicationRoot',
'automationApplyRoot',
'automationDecisionRoot',
'automationRoot',
'credentialFilePath',
'deploymentRoot',
'ownerPepperKeyringDirectory',
'targetDatabasePath',
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
],
'options',
);
const identity = currentIdentity();
if (
typeof selected.allowRootService !== 'boolean' ||
(identity.uid === 0) !== selected.allowRootService
)
fail('command identity is invalid');
const normalized = {
deploymentRoot: safePath(selected.deploymentRoot, 'deploymentRoot'),
applicationRoot: safePath(selected.applicationRoot, 'applicationRoot'),
automationRoot: safePath(selected.automationRoot, 'automationRoot'),
automationDecisionRoot: safePath(
selected.automationDecisionRoot,
'automationDecisionRoot',
),
automationApplyRoot: safePath(
selected.automationApplyRoot,
'automationApplyRoot',
),
targetDatabasePath: safePath(
selected.targetDatabasePath,
'targetDatabasePath',
),
ownerPepperKeyringDirectory: safePath(
selected.ownerPepperKeyringDirectory,
'ownerPepperKeyringDirectory',
),
credentialFilePath: safePath(
selected.credentialFilePath,
'credentialFilePath',
),
};
const roots = [
normalized.deploymentRoot,
normalized.applicationRoot,
normalized.automationRoot,
normalized.automationDecisionRoot,
normalized.automationApplyRoot,
];
for (let i = 0; i < roots.length; i += 1) {
for (let j = i + 1; j < roots.length; j += 1) {
if (overlaps(roots[i]!, roots[j]!) || overlaps(roots[j]!, roots[i]!)) {
fail('authority roots overlap');
}
}
}
for (const candidate of [
normalized.ownerPepperKeyringDirectory,
normalized.credentialFilePath,
]) {
const relative = path.relative(normalized.deploymentRoot, candidate);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
fail('authentication material must be below deploymentRoot');
}
}
if (
roots.some(
(root) =>
overlaps(root, normalized.targetDatabasePath) ||
overlaps(normalized.targetDatabasePath, root),
)
) {
fail('targetDatabasePath overlaps an authority root');
}
if (
selected.busyTimeoutMs !== undefined &&
(!Number.isSafeInteger(selected.busyTimeoutMs) ||
(selected.busyTimeoutMs as number) < 1 ||
(selected.busyTimeoutMs as number) > 60_000)
) {
fail('busyTimeoutMs is invalid');
}
return Object.freeze({
...normalized,
allowRootService: selected.allowRootService as boolean,
...(selected.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: selected.busyTimeoutMs as number }),
});
}
function command(value: unknown, operation: string) {
const selected = record(value, 'command');
exact(
selected,
['operation', 'options', 'request', 'schemaVersion'],
'command',
);
if (selected.schemaVersion !== 1 || selected.operation !== operation)
fail('command version or operation is invalid');
return {
options: options(selected.options),
request: record(selected.request, 'request'),
} as const;
}
function id(value: unknown, pattern: RegExp, label: string): string {
if (typeof value !== 'string' || !pattern.test(value))
fail(`${label} is invalid`);
return value;
}
function digest(value: unknown, label: string): string {
return id(value, DIGEST, label);
}
export function normalizeLocalReconciliationAutomationApplyCommand(
value: unknown,
): Readonly<LocalReconciliationAutomationApplyCommand> {
const selected = command(
value,
'local.deployment.reconciliation.automation.apply',
);
exact(
selected.request,
[
'appliedAtMs',
'automationId',
'decisionId',
'expectedDecisionDigest',
'expectedHeadDigest',
'mutationId',
'requestId',
],
'request',
);
if (
!Number.isSafeInteger(selected.request.appliedAtMs) ||
(selected.request.appliedAtMs as number) < 0 ||
typeof selected.request.requestId !== 'string' ||
!SAFE_ID.test(selected.request.requestId)
)
fail('apply request timestamp or requestId is invalid');
return Object.freeze({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.apply',
options: selected.options,
request: Object.freeze({
decisionId: id(selected.request.decisionId, UUID_V7, 'decisionId'),
automationId: id(selected.request.automationId, UUID_V4, 'automationId'),
expectedDecisionDigest: digest(
selected.request.expectedDecisionDigest,
'expectedDecisionDigest',
),
expectedHeadDigest: digest(
selected.request.expectedHeadDigest,
'expectedHeadDigest',
),
mutationId: id(selected.request.mutationId, UUID_V4, 'mutationId'),
requestId: selected.request.requestId,
appliedAtMs: selected.request.appliedAtMs as number,
}),
});
}
export function normalizeLocalReconciliationAutomationApplyVerifyCommand(
value: unknown,
): Readonly<LocalReconciliationAutomationApplyVerifyCommand> {
const selected = command(
value,
'local.deployment.reconciliation.automation.apply.verify',
);
exact(
selected.request,
['automationId', 'decisionId', 'expectedApplyDigest'],
'request',
);
return Object.freeze({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.apply.verify',
options: selected.options,
request: Object.freeze({
decisionId: id(selected.request.decisionId, UUID_V7, 'decisionId'),
automationId: id(selected.request.automationId, UUID_V4, 'automationId'),
expectedApplyDigest: digest(
selected.request.expectedApplyDigest,
'expectedApplyDigest',
),
}),
});
}
export function normalizeLocalReconciliationAutomationApplyRollbackCommand(
value: unknown,
): Readonly<LocalReconciliationAutomationApplyRollbackCommand> {
const selected = command(
value,
'local.deployment.reconciliation.automation.apply.rollback',
);
exact(
selected.request,
[
'automationId',
'decisionId',
'expectedApplyDigest',
'expectedHeadDigest',
'rolledBackAtMs',
],
'request',
);
if (
!Number.isSafeInteger(selected.request.rolledBackAtMs) ||
(selected.request.rolledBackAtMs as number) < 0
)
fail('rolledBackAtMs is invalid');
return Object.freeze({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.apply.rollback',
options: selected.options,
request: Object.freeze({
decisionId: id(selected.request.decisionId, UUID_V7, 'decisionId'),
automationId: id(selected.request.automationId, UUID_V4, 'automationId'),
expectedApplyDigest: digest(
selected.request.expectedApplyDigest,
'expectedApplyDigest',
),
expectedHeadDigest: digest(
selected.request.expectedHeadDigest,
'expectedHeadDigest',
),
rolledBackAtMs: selected.request.rolledBackAtMs as number,
}),
});
}
@@ -0,0 +1,927 @@
import fs from 'node:fs';
import path from 'node:path';
import { applyReconciliationAutomationDecision } from '@qinglong/local-admin/reconciliation-automation-decision';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import {
establishAuthenticatedLocalCommand,
type AuthenticatedLocalCommand,
} from '@qinglong/local-owner-console/authenticated-command';
import { openLocalSqliteAuthenticationReadDatabase } from '@qinglong/local-sqlite/authentication-read';
import {
createLocalSqliteRolloutBackup,
inspectLocalSqliteSnapshot,
restoreLocalSqliteSnapshot,
} from '@qinglong/local-sqlite/rollout-safety';
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
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 {
inspectLocalReconciliationSealedBundle,
withLocalReconciliationSealedDatabaseAsync,
} from '../../sealed-bundle/reader';
import { readLocalReconciliationCaptureIntent } from '../../preparation';
import { proveLocalReconciliationStoppedState } from '../../stoppedProof';
import { LocalReconciliationReviewIssuerKeyringFileProvider } from '../../review/issuerKeyring';
import {
normalizeLocalReconciliationAutomationApplyCommand,
normalizeLocalReconciliationAutomationApplyRollbackCommand,
normalizeLocalReconciliationAutomationApplyVerifyCommand,
type LocalReconciliationAutomationApplyCommand,
type LocalReconciliationAutomationApplyOptions,
type LocalReconciliationAutomationApplyResult,
} from './applyContract';
import {
buildLocalReconciliationAutomationApplyIntent,
buildLocalReconciliationAutomationApplyReceipt,
buildLocalReconciliationAutomationRollbackReceipt,
localReconciliationAutomationApplyEvidenceContents,
normalizeLocalReconciliationAutomationApplyIntent,
normalizeLocalReconciliationAutomationApplyReceipt,
normalizeLocalReconciliationAutomationRollbackReceipt,
type LocalReconciliationAutomationApplyIntent,
type LocalReconciliationAutomationApplyReceipt,
} from './applyEvidence';
import { readLocalReconciliationAutomationDecisionTerminal } from './decisionCoordinator';
import { createLocalReconciliationAutomationRequirementFactory } from './planReader';
const MAX_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000;
interface ApplyPaths {
root: string;
backupRoot: string;
intent: string;
backup: string;
receipt: string;
rollback: string;
restoreStage: string;
replaced: string;
}
type AuthenticationDatabase = Awaited<
ReturnType<typeof openLocalSqliteAuthenticationReadDatabase>
>;
export interface LocalReconciliationAutomationApplyDependencies {
readonly openAuthenticationDatabase?: typeof openLocalSqliteAuthenticationReadDatabase;
readonly authenticate?: typeof establishAuthenticatedLocalCommand;
readonly createBackup?: typeof createLocalSqliteRolloutBackup;
readonly inspectSnapshot?: typeof inspectLocalSqliteSnapshot;
readonly restoreSnapshot?: typeof restoreLocalSqliteSnapshot;
readonly afterBackupPublished?: () => void;
readonly afterPreparedHead?: () => void;
readonly afterDatabaseCommit?: () => void;
readonly afterReceiptPublished?: () => void;
readonly afterAppliedHead?: () => void;
readonly afterRestore?: () => void;
readonly afterRollbackReceipt?: () => void;
}
function fail(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(
`reconciliation automation apply ${message}`,
{ cause },
);
}
function paths(root: string, automationId: string): Readonly<ApplyPaths> {
const selected = path.join(root, automationId);
const backupRoot = path.join(selected, 'backup');
return Object.freeze({
root: selected,
backupRoot,
intent: path.join(selected, 'intent.json'),
backup: path.join(backupRoot, 'before.sqlite'),
receipt: path.join(selected, 'receipt.json'),
rollback: path.join(selected, 'rollback.json'),
restoreStage: path.join(backupRoot, 'restore-stage.sqlite'),
replaced: path.join(backupRoot, 'replaced.sqlite'),
});
}
function decisionOptions(
options: Readonly<LocalReconciliationAutomationApplyOptions>,
) {
return Object.freeze({
deploymentRoot: options.deploymentRoot,
applicationRoot: options.applicationRoot,
automationRoot: options.automationRoot,
automationDecisionRoot: options.automationDecisionRoot,
allowRootService: options.allowRootService,
});
}
function readIntent(
selected: Readonly<ApplyPaths>,
): Readonly<LocalReconciliationAutomationApplyIntent> {
return normalizeLocalReconciliationAutomationApplyIntent(
readPrivateLocalCommandFile(selected.intent),
);
}
function readReceipt(
selected: Readonly<ApplyPaths>,
): Readonly<LocalReconciliationAutomationApplyReceipt> {
return normalizeLocalReconciliationAutomationApplyReceipt(
readPrivateLocalCommandFile(selected.receipt),
);
}
function validateCatalog(selected: Readonly<ApplyPaths>): void {
const allowed = new Set([
'backup',
'intent.json',
'receipt.json',
'rollback.json',
'.intent.json.ql3-deploy-stage',
'.receipt.json.ql3-deploy-stage',
'.rollback.json.ql3-deploy-stage',
]);
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
if (!allowed.has(entry.name) || entry.isSymbolicLink())
fail('apply root contains unknown material');
}
const backupAllowed = new Set([
'before.sqlite',
'.before.sqlite.ql3-backup-stage',
'restore-stage.sqlite',
'replaced.sqlite',
]);
for (const entry of fs.readdirSync(selected.backupRoot, {
withFileTypes: true,
})) {
if (!backupAllowed.has(entry.name) || entry.isSymbolicLink())
fail('backup root contains unknown material');
}
}
function advance(
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
uid: number,
state:
| 'reconciliation_automation_apply_prepared'
| 'reconciliation_automation_applied'
| 'reconciliation_automation_rolled_back',
digest: string,
atMs: number,
): Readonly<LocalCutoverInstanceHead> {
return advanceLocalCutoverInstanceHead(
{
options: { deploymentRoot: intent.command.options.deploymentRoot },
request: {
cutoverId: intent.cutoverId,
profile: intent.profile,
instanceId: intent.instanceId,
expectedActivationDigest: intent.activationDigest,
requestedAtMs: atMs,
},
},
uid,
state,
intent.generation,
digest,
);
}
function result(
operation: LocalReconciliationAutomationApplyResult['operation'],
status: LocalReconciliationAutomationApplyResult['status'],
receipt: Readonly<LocalReconciliationAutomationApplyReceipt>,
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
head: Readonly<LocalCutoverInstanceHead>,
): Readonly<LocalReconciliationAutomationApplyResult> {
return Object.freeze({
schemaVersion: 1,
operation,
status,
state: head.state as
| 'reconciliation_automation_applied'
| 'reconciliation_automation_rolled_back',
decisionId: receipt.decisionId,
automationId: receipt.automationId,
applyDigest: receipt.applyDigest,
publicationDigest: receipt.publicationDigest,
adoptedTaskCount: receipt.adoptedTaskCount,
adoptedTriggerCount: receipt.adoptedTriggerCount,
backupSha256: intent.backup.sha256,
instanceHeadDigest: head.headDigest,
});
}
function assertReviewer(
authenticated: Readonly<AuthenticatedLocalCommand>,
reviewer: Readonly<SecurityPrincipal>,
atMs: number,
): void {
const principal = authenticated.principal;
if (
reviewer.subject.type !== 'user' ||
principal.subject.type !== 'user' ||
reviewer.subject.id !== principal.subject.id ||
principal.assurance !== 'local_console' ||
principal.authenticatedAtMs > atMs ||
atMs - principal.authenticatedAtMs > MAX_AUTHENTICATION_AGE_MS ||
principal.expiresAtMs <= atMs
)
fail('current reviewer authentication is not strong or identical');
}
async function authenticate(
options: Readonly<LocalReconciliationAutomationApplyOptions>,
atMs: number,
profile: 'edge' | 'standalone',
reviewer: Readonly<SecurityPrincipal>,
dependencies: LocalReconciliationAutomationApplyDependencies,
): Promise<
Readonly<{
authenticated: Readonly<AuthenticatedLocalCommand>;
database: AuthenticationDatabase;
}>
> {
const open =
dependencies.openAuthenticationDatabase ??
openLocalSqliteAuthenticationReadDatabase;
const database = await open({
databasePath: options.targetDatabasePath,
profile,
...(options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: options.busyTimeoutMs }),
});
try {
const selected = await (
dependencies.authenticate ?? establishAuthenticatedLocalCommand
)(database, {
deploymentRoot: options.deploymentRoot,
databasePath: options.targetDatabasePath,
ownerPepperKeyringDirectory: options.ownerPepperKeyringDirectory,
credentialFilePath: options.credentialFilePath,
authenticationNamespace: 'local_reconciliation_automation_apply',
now: () => atMs,
});
assertReviewer(selected, reviewer, atMs);
return Object.freeze({ authenticated: selected, database });
} catch (error) {
await database.close();
throw error;
}
}
function exactBundle(
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
captureRoot: string,
captureId: string,
expectedBundleDigest: string,
expectedFingerprintDigest: string,
uid: number,
): void {
const current = inspectLocalReconciliationSealedBundle(
captureRoot,
captureId,
uid,
);
if (
current.receipt.bundleDigest !== expectedBundleDigest ||
current.fingerprintDigest !== expectedFingerprintDigest ||
intent.command.request.expectedDecisionDigest.length !== 64
)
fail('sealed source authority drifted');
}
function verifyIntentCommand(
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
command: Readonly<LocalReconciliationAutomationApplyCommand>,
): void {
if (
intent.command.request.automationId !== command.request.automationId ||
intent.command.request.decisionId !== command.request.decisionId ||
intent.command.request.expectedDecisionDigest !==
command.request.expectedDecisionDigest ||
intent.command.request.mutationId !== command.request.mutationId ||
intent.command.request.requestId !== command.request.requestId ||
intent.command.request.appliedAtMs !== command.request.appliedAtMs ||
intent.command.request.expectedHeadDigest !==
command.request.expectedHeadDigest ||
JSON.stringify(intent.command.options) !== JSON.stringify(command.options)
)
fail('apply command is not an exact replay');
}
export async function applyLocalReconciliationAutomation(
value: unknown,
dependencies: LocalReconciliationAutomationApplyDependencies = {},
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
const command = normalizeLocalReconciliationAutomationApplyCommand(value);
const uid = currentIdentity().uid;
for (const [directory, label] of [
[command.options.deploymentRoot, 'deploymentRoot'],
[command.options.applicationRoot, 'applicationRoot'],
[command.options.automationRoot, 'automationRoot'],
[command.options.automationDecisionRoot, 'automationDecisionRoot'],
[command.options.automationApplyRoot, 'automationApplyRoot'],
] as const)
validatePrivateDirectory(directory, uid, label);
const selected = paths(
command.options.automationApplyRoot,
command.request.automationId,
);
ensurePrivateDirectory(selected.root, uid, 'automation apply root');
ensurePrivateDirectory(
selected.backupRoot,
uid,
'automation apply backup root',
);
validateCatalog(selected);
const terminal = await readLocalReconciliationAutomationDecisionTerminal(
decisionOptions(command.options),
command.request.automationId,
uid,
);
if (
terminal.receipt.decisionId !== command.request.decisionId ||
terminal.receipt.decisionDigest !== command.request.expectedDecisionDigest
)
fail('apply command is detached from signed decision');
const capture = readLocalReconciliationCaptureIntent(
terminal.plan.intent.command.options.captureRoot,
terminal.plan.intent.command.request.captureId,
);
if (
capture.command.request.targetDatabasePath !==
command.options.targetDatabasePath
) {
fail('target database is detached from stopped capture');
}
let intent: Readonly<LocalReconciliationAutomationApplyIntent>;
const recoveringPreparedIntent = fs.existsSync(selected.intent);
let head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
terminal.intent.instanceId,
uid,
);
if (fs.existsSync(selected.intent)) {
intent = readIntent(selected);
verifyIntentCommand(intent, command);
} else {
if (
head.state !== 'reconciliation_automation_reviewed' ||
head.headDigest !== command.request.expectedHeadDigest ||
head.sourceRecordDigest !== terminal.receipt.decisionDigest
)
fail('apply lost reviewed head compare-and-swap');
const before = proveLocalReconciliationStoppedState(capture.command, uid);
const backup = await (
dependencies.createBackup ?? createLocalSqliteRolloutBackup
)({
databasePath: command.options.targetDatabasePath,
backupPath: selected.backup,
profile: terminal.intent.profile,
...(command.options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: command.options.busyTimeoutMs }),
});
const after = proveLocalReconciliationStoppedState(capture.command, uid);
if (after.proofDigest !== before.proofDigest)
fail('stopped target drifted across backup');
dependencies.afterBackupPublished?.();
intent = buildLocalReconciliationAutomationApplyIntent({
command,
instanceId: terminal.intent.instanceId,
cutoverId: terminal.intent.cutoverId,
activationDigest: terminal.intent.activationDigest,
profile: terminal.intent.profile,
projectId: terminal.intent.projectId,
generation: terminal.intent.generation,
stoppedProofDigest: before.proofDigest,
backup,
});
publishExactFile(
selected.intent,
localReconciliationAutomationApplyEvidenceContents(intent),
0o600,
uid,
'automation apply intent',
);
head = advance(
intent,
uid,
'reconciliation_automation_apply_prepared',
intent.preparationDigest,
command.request.appliedAtMs,
);
dependencies.afterPreparedHead?.();
}
if (fs.existsSync(selected.receipt)) {
const receipt = readReceipt(selected);
const current = await (
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
)({
databasePath: command.options.targetDatabasePath,
profile: intent.profile,
});
if (current.sha256 !== receipt.targetAfter.sha256)
fail('terminal apply target drifted');
if (
head.state === 'reconciliation_automation_apply_prepared' &&
head.sourceRecordDigest === intent.preparationDigest
) {
head = advance(
intent,
uid,
'reconciliation_automation_applied',
receipt.applyDigest,
receipt.appliedAtMs,
);
dependencies.afterAppliedHead?.();
} else if (
head.state !== 'reconciliation_automation_applied' ||
receipt.applyDigest !== head.sourceRecordDigest
)
fail('terminal apply receipt drifted');
return result(command.operation, 'existing', receipt, intent, head);
}
if (
head.state !== 'reconciliation_automation_apply_prepared' ||
head.sourceRecordDigest !== intent.preparationDigest
)
fail('apply lost prepared head compare-and-swap');
if (!recoveringPreparedIntent) {
const stopped = proveLocalReconciliationStoppedState(capture.command, uid);
if (stopped.proofDigest !== intent.stoppedProofDigest)
fail('stopped proof drifted before write');
}
const authenticatedScope = await authenticate(
command.options,
command.request.appliedAtMs,
intent.profile,
terminal.reviewer,
dependencies,
);
let publication;
try {
const openRequirements =
createLocalReconciliationAutomationRequirementFactory(
terminal.context.automation.planPath,
terminal.context.automation.receipt,
uid,
);
const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider(
terminal.context.application.intent.command.options.issuerKeyringPath,
);
const applied = await withLocalReconciliationSealedDatabaseAsync(
terminal.plan.bundle,
'legacy',
uid,
{},
async (sourceClient) =>
applyReconciliationAutomationDecision({
authorizationPath: terminal.authorizationPath,
decisionId: command.request.decisionId,
profile: intent.profile,
automationPlanDigest: terminal.receipt.automationPlanDigest,
inventoryDigest: terminal.receipt.legacyInventoryDigest,
sourceClient,
timezone: terminal.intent.legacyTimezone,
keyProvider,
observedAtMs: command.request.appliedAtMs,
openRequirements,
allowedModes: [0o400],
allowedParentModes: [0o500],
targetPath: command.options.targetDatabasePath,
projectId: intent.projectId,
mutationId: command.request.mutationId,
requestId: command.request.requestId,
confirmSourceIdentity() {
exactBundle(
intent,
terminal.plan.bundle.captureRoot,
terminal.plan.receipt.captureId,
terminal.plan.bundle.receipt.bundleDigest,
terminal.plan.bundle.fingerprintDigest,
uid,
);
const currentHead = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
intent.instanceId,
uid,
);
if (
currentHead.state !==
'reconciliation_automation_apply_prepared' ||
currentHead.sourceRecordDigest !== intent.preparationDigest
)
fail('apply lost write head authority');
},
async confirmReviewerAuthority(reviewer) {
assertReviewer(
authenticatedScope.authenticated,
reviewer,
command.request.appliedAtMs,
);
await authenticatedScope.authenticated.confirm();
},
}),
);
if (applied === null) fail('sealed Legacy source requires manual handling');
publication = applied;
} finally {
await authenticatedScope.database.close();
}
dependencies.afterDatabaseCommit?.();
const targetAfter = await (
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
)({
databasePath: command.options.targetDatabasePath,
profile: intent.profile,
});
const receipt = buildLocalReconciliationAutomationApplyReceipt({
decisionId: command.request.decisionId,
automationId: command.request.automationId,
mutationId: command.request.mutationId,
preparationDigest: intent.preparationDigest,
preparedHeadDigest: head.headDigest,
publicationDigest: publication.adoption.publicationDigest,
adoptedTaskCount: publication.adoption.adoptedTaskCount,
adoptedTriggerCount: publication.adoption.adoptedTriggerCount,
skippedCount: publication.adoption.skippedCount,
targetAfter,
appliedAtMs: command.request.appliedAtMs,
});
preflightPublishedFile(
selected.receipt,
localReconciliationAutomationApplyEvidenceContents(receipt),
0o600,
uid,
'automation apply receipt',
);
publishExactFile(
selected.receipt,
localReconciliationAutomationApplyEvidenceContents(receipt),
0o600,
uid,
'automation apply receipt',
);
dependencies.afterReceiptPublished?.();
head = advance(
intent,
uid,
'reconciliation_automation_applied',
receipt.applyDigest,
command.request.appliedAtMs,
);
dependencies.afterAppliedHead?.();
return result(
command.operation,
publication.status === 'existing' ? 'existing' : 'applied',
receipt,
intent,
head,
);
}
export async function verifyLocalReconciliationAutomationApply(
value: unknown,
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
const command =
normalizeLocalReconciliationAutomationApplyVerifyCommand(value);
const uid = currentIdentity().uid;
const selected = paths(
command.options.automationApplyRoot,
command.request.automationId,
);
validatePrivateDirectory(
command.options.automationApplyRoot,
uid,
'automationApplyRoot',
);
validatePrivateDirectory(selected.root, uid, 'automation apply root');
validatePrivateDirectory(
selected.backupRoot,
uid,
'automation apply backup root',
);
validateCatalog(selected);
const intent = readIntent(selected);
const receipt = readReceipt(selected);
if (
intent.command.request.decisionId !== command.request.decisionId ||
receipt.decisionId !== command.request.decisionId ||
receipt.automationId !== command.request.automationId ||
receipt.applyDigest !== command.request.expectedApplyDigest
)
fail('verify command is detached from apply receipt');
await readLocalReconciliationAutomationDecisionTerminal(
decisionOptions(command.options),
command.request.automationId,
uid,
);
const head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
intent.instanceId,
uid,
);
if (head.state === 'reconciliation_automation_applied') {
if (head.sourceRecordDigest !== receipt.applyDigest)
fail('applied head drifted');
const current = await inspectLocalSqliteSnapshot({
databasePath: command.options.targetDatabasePath,
profile: intent.profile,
});
if (current.sha256 !== receipt.targetAfter.sha256)
fail('applied target drifted');
return result(command.operation, 'verified', receipt, intent, head);
}
if (
head.state === 'reconciliation_automation_rolled_back' &&
fs.existsSync(selected.rollback)
) {
const rollback = normalizeLocalReconciliationAutomationRollbackReceipt(
readPrivateLocalCommandFile(selected.rollback),
);
if (
rollback.applyDigest !== receipt.applyDigest ||
head.sourceRecordDigest !== rollback.rollbackDigest
)
fail('rollback head drifted');
const current = await inspectLocalSqliteSnapshot({
databasePath: command.options.targetDatabasePath,
profile: intent.profile,
});
if (
current.sha256 !== intent.backup.sha256 ||
current.sha256 !== rollback.restored.sha256
)
fail('rolled-back target drifted');
return result(command.operation, 'verified', receipt, intent, head);
}
return fail('apply head is not terminal');
}
export async function rollbackLocalReconciliationAutomationApply(
value: unknown,
dependencies: LocalReconciliationAutomationApplyDependencies = {},
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
const command =
normalizeLocalReconciliationAutomationApplyRollbackCommand(value);
const uid = currentIdentity().uid;
const selected = paths(
command.options.automationApplyRoot,
command.request.automationId,
);
validatePrivateDirectory(
command.options.automationApplyRoot,
uid,
'automationApplyRoot',
);
validatePrivateDirectory(selected.root, uid, 'automation apply root');
validatePrivateDirectory(
selected.backupRoot,
uid,
'automation apply backup root',
);
validateCatalog(selected);
const intent = readIntent(selected);
const receipt = readReceipt(selected);
if (
receipt.decisionId !== command.request.decisionId ||
receipt.automationId !== command.request.automationId ||
receipt.applyDigest !== command.request.expectedApplyDigest
)
fail('rollback command is detached from apply receipt');
const terminal = await readLocalReconciliationAutomationDecisionTerminal(
decisionOptions(command.options),
command.request.automationId,
uid,
);
let head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
intent.instanceId,
uid,
);
if (fs.existsSync(selected.rollback)) {
const rollback = normalizeLocalReconciliationAutomationRollbackReceipt(
readPrivateLocalCommandFile(selected.rollback),
);
const current = await (
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
)({
databasePath: command.options.targetDatabasePath,
profile: intent.profile,
});
if (current.sha256 !== rollback.restored.sha256)
fail('rollback replay target drifted');
if (
head.state === 'reconciliation_automation_applied' &&
head.sourceRecordDigest === receipt.applyDigest
) {
head = advance(
intent,
uid,
'reconciliation_automation_rolled_back',
rollback.rollbackDigest,
rollback.rolledBackAtMs,
);
} else if (
head.state !== 'reconciliation_automation_rolled_back' ||
head.sourceRecordDigest !== rollback.rollbackDigest
)
fail('rollback replay drifted');
return result(command.operation, 'existing', receipt, intent, head);
}
if (
head.state !== 'reconciliation_automation_applied' ||
head.headDigest !== command.request.expectedHeadDigest ||
head.sourceRecordDigest !== receipt.applyDigest
)
fail('rollback lost applied head compare-and-swap');
const current = await (
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
)({
databasePath: command.options.targetDatabasePath,
profile: intent.profile,
});
if (current.sha256 === intent.backup.sha256) {
const rollback = buildLocalReconciliationAutomationRollbackReceipt({
decisionId: receipt.decisionId,
automationId: receipt.automationId,
applyDigest: receipt.applyDigest,
restored: current,
rolledBackAtMs: command.request.rolledBackAtMs,
});
publishExactFile(
selected.rollback,
localReconciliationAutomationApplyEvidenceContents(rollback),
0o600,
uid,
'automation rollback receipt',
);
dependencies.afterRollbackReceipt?.();
head = advance(
intent,
uid,
'reconciliation_automation_rolled_back',
rollback.rollbackDigest,
command.request.rolledBackAtMs,
);
return result(command.operation, 'existing', receipt, intent, head);
}
if (current.sha256 !== receipt.targetAfter.sha256)
fail('rollback current target drifted');
const authenticatedScope = await authenticate(
command.options,
command.request.rolledBackAtMs,
intent.profile,
terminal.reviewer,
dependencies,
);
try {
const openRequirements =
createLocalReconciliationAutomationRequirementFactory(
terminal.context.automation.planPath,
terminal.context.automation.receipt,
uid,
);
const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider(
terminal.context.application.intent.command.options.issuerKeyringPath,
);
const replay = await withLocalReconciliationSealedDatabaseAsync(
terminal.plan.bundle,
'legacy',
uid,
{},
async (sourceClient) =>
applyReconciliationAutomationDecision({
authorizationPath: terminal.authorizationPath,
decisionId: receipt.decisionId,
profile: intent.profile,
automationPlanDigest: terminal.receipt.automationPlanDigest,
inventoryDigest: terminal.receipt.legacyInventoryDigest,
sourceClient,
timezone: terminal.intent.legacyTimezone,
keyProvider,
observedAtMs: intent.command.request.appliedAtMs,
openRequirements,
allowedModes: [0o400],
allowedParentModes: [0o500],
targetPath: command.options.targetDatabasePath,
projectId: intent.projectId,
mutationId: intent.command.request.mutationId,
requestId: intent.command.request.requestId,
confirmSourceIdentity() {
const currentHead = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
intent.instanceId,
uid,
);
if (
currentHead.state !== 'reconciliation_automation_applied' ||
currentHead.sourceRecordDigest !== receipt.applyDigest
)
fail('rollback lost current policy fence');
},
async confirmReviewerAuthority(reviewer) {
assertReviewer(
authenticatedScope.authenticated,
reviewer,
command.request.rolledBackAtMs,
);
await authenticatedScope.authenticated.confirm();
},
}),
);
if (
replay === null ||
replay.status !== 'existing' ||
replay.adoption.publicationDigest !== receipt.publicationDigest
)
fail('rollback could not verify current publication');
} finally {
await authenticatedScope.database.close();
}
const restored = await (
dependencies.restoreSnapshot ?? restoreLocalSqliteSnapshot
)({
databasePath: command.options.targetDatabasePath,
sourceSnapshotPath: selected.backup,
restoreStagePath: selected.restoreStage,
replacedDatabasePath: selected.replaced,
expectedCurrentSha256: receipt.targetAfter.sha256,
expectedSourceSha256: intent.backup.sha256,
preserveDatabaseIdentity: true,
profile: intent.profile,
...(command.options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: command.options.busyTimeoutMs }),
});
dependencies.afterRestore?.();
const rollback = buildLocalReconciliationAutomationRollbackReceipt({
decisionId: receipt.decisionId,
automationId: receipt.automationId,
applyDigest: receipt.applyDigest,
restored: Object.freeze({
contractVersion: restored.contractVersion,
sha256: restored.sha256,
bytes: restored.bytes,
pageCount: restored.pageCount,
pageSize: restored.pageSize,
}),
rolledBackAtMs: command.request.rolledBackAtMs,
});
publishExactFile(
selected.rollback,
localReconciliationAutomationApplyEvidenceContents(rollback),
0o600,
uid,
'automation rollback receipt',
);
dependencies.afterRollbackReceipt?.();
head = advance(
intent,
uid,
'reconciliation_automation_rolled_back',
rollback.rollbackDigest,
command.request.rolledBackAtMs,
);
return result(command.operation, 'rolled_back', receipt, intent, head);
}
export async function applyLocalReconciliationAutomationCommandFile(
filePath: string,
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
return applyLocalReconciliationAutomation(
readPrivateLocalCommandFile(filePath),
);
}
export async function verifyLocalReconciliationAutomationApplyCommandFile(
filePath: string,
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
return verifyLocalReconciliationAutomationApply(
readPrivateLocalCommandFile(filePath),
);
}
export async function rollbackLocalReconciliationAutomationApplyCommandFile(
filePath: string,
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
return rollbackLocalReconciliationAutomationApply(
readPrivateLocalCommandFile(filePath),
);
}
@@ -0,0 +1,351 @@
import type {
LocalSqliteRolloutBackupEvidence,
LocalSqliteSnapshotEvidence,
} from '@qinglong/local-sqlite/rollout-safety';
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
import { cutoverDigest } from '../../../cutover/targetEvidence';
import {
normalizeLocalReconciliationAutomationApplyCommand,
type LocalReconciliationAutomationApplyCommand,
} from './applyContract';
const INTENT_SCHEMA = 'qinglong3-local-reconciliation-automation-apply-intent';
const RECEIPT_SCHEMA =
'qinglong3-local-reconciliation-automation-apply-receipt';
const ROLLBACK_SCHEMA =
'qinglong3-local-reconciliation-automation-rollback-receipt';
const DIGEST = /^[0-9a-f]{64}$/;
export interface LocalReconciliationAutomationApplyIntent {
readonly schema: typeof INTENT_SCHEMA;
readonly schemaVersion: 1;
readonly command: Readonly<LocalReconciliationAutomationApplyCommand>;
readonly instanceId: string;
readonly cutoverId: string;
readonly activationDigest: string;
readonly profile: 'edge' | 'standalone';
readonly projectId: string;
readonly generation: number;
readonly stoppedProofDigest: string;
readonly backup: Readonly<LocalSqliteRolloutBackupEvidence>;
readonly preparationDigest: string;
}
export interface LocalReconciliationAutomationApplyReceipt {
readonly schema: typeof RECEIPT_SCHEMA;
readonly schemaVersion: 1;
readonly state: 'reconciliation_automation_applied';
readonly decisionId: string;
readonly automationId: string;
readonly mutationId: string;
readonly preparationDigest: string;
readonly preparedHeadDigest: string;
readonly publicationDigest: string;
readonly adoptedTaskCount: number;
readonly adoptedTriggerCount: number;
readonly skippedCount: number;
readonly targetAfter: Readonly<LocalSqliteSnapshotEvidence>;
readonly appliedAtMs: number;
readonly applyDigest: string;
}
export interface LocalReconciliationAutomationRollbackReceipt {
readonly schema: typeof ROLLBACK_SCHEMA;
readonly schemaVersion: 1;
readonly state: 'reconciliation_automation_rolled_back';
readonly decisionId: string;
readonly automationId: string;
readonly applyDigest: string;
readonly restored: Readonly<LocalSqliteSnapshotEvidence>;
readonly rolledBackAtMs: number;
readonly rollbackDigest: string;
}
function fail(message: string): never {
throw new LocalDeploymentConfigurationError(
`reconciliation automation apply evidence ${message}`,
);
}
function exact(
value: unknown,
keys: readonly string[],
label: string,
): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
fail(`${label} must be an object`);
const record = value as Record<string, unknown>;
const actual = Object.keys(record).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
)
fail(`${label} shape is invalid`);
return record;
}
function validDigest(value: unknown): value is string {
return typeof value === 'string' && DIGEST.test(value);
}
function snapshot(
value: unknown,
label: string,
): Readonly<LocalSqliteSnapshotEvidence> {
const selected = exact(
value,
['bytes', 'contractVersion', 'pageCount', 'pageSize', 'sha256'],
label,
);
if (
!validDigest(selected.sha256) ||
![selected.bytes, selected.pageCount, selected.pageSize].every(
(item) => Number.isSafeInteger(item) && (item as number) > 0,
) ||
typeof selected.contractVersion !== 'number'
)
fail(`${label} is invalid`);
return Object.freeze({
contractVersion: selected.contractVersion,
sha256: selected.sha256,
bytes: selected.bytes,
pageCount: selected.pageCount,
pageSize: selected.pageSize,
}) as unknown as Readonly<LocalSqliteSnapshotEvidence>;
}
function backup(value: unknown): Readonly<LocalSqliteRolloutBackupEvidence> {
const selected = exact(
value,
[
'bytes',
'contractVersion',
'pageCount',
'pageSize',
'sha256',
'status',
'writeContractVersion',
],
'backup',
);
const base = snapshot(
{
bytes: selected.bytes,
contractVersion: selected.contractVersion,
pageCount: selected.pageCount,
pageSize: selected.pageSize,
sha256: selected.sha256,
},
'backup snapshot',
);
if (
(selected.status !== 'prepared' && selected.status !== 'existing') ||
typeof selected.writeContractVersion !== 'number'
)
fail('backup is invalid');
return Object.freeze({
status: selected.status,
writeContractVersion: selected.writeContractVersion,
...base,
}) as Readonly<LocalSqliteRolloutBackupEvidence>;
}
export function buildLocalReconciliationAutomationApplyIntent(
input: Omit<
LocalReconciliationAutomationApplyIntent,
'schema' | 'schemaVersion' | 'preparationDigest'
>,
): Readonly<LocalReconciliationAutomationApplyIntent> {
const payload = Object.freeze({
schema: INTENT_SCHEMA,
schemaVersion: 1 as const,
...input,
});
return Object.freeze({
...payload,
preparationDigest: cutoverDigest(payload),
});
}
export function normalizeLocalReconciliationAutomationApplyIntent(
value: unknown,
): Readonly<LocalReconciliationAutomationApplyIntent> {
const selected = exact(
value,
[
'activationDigest',
'backup',
'command',
'cutoverId',
'generation',
'instanceId',
'preparationDigest',
'profile',
'projectId',
'schema',
'schemaVersion',
'stoppedProofDigest',
],
'intent',
);
const { preparationDigest, ...raw } = selected;
const command = normalizeLocalReconciliationAutomationApplyCommand(
selected.command,
);
const normalized = Object.freeze({
...raw,
command,
backup: backup(selected.backup),
});
if (
selected.schema !== INTENT_SCHEMA ||
selected.schemaVersion !== 1 ||
!validDigest(preparationDigest) ||
cutoverDigest(raw) !== preparationDigest ||
cutoverDigest(normalized) !== preparationDigest ||
!validDigest(selected.activationDigest) ||
!validDigest(selected.stoppedProofDigest) ||
(selected.profile !== 'edge' && selected.profile !== 'standalone') ||
!Number.isSafeInteger(selected.generation) ||
(selected.generation as number) < 1 ||
![selected.instanceId, selected.cutoverId, selected.projectId].every(
(item) => typeof item === 'string' && item.length > 0,
)
)
fail('intent binding is invalid');
return Object.freeze({
...normalized,
preparationDigest,
}) as unknown as Readonly<LocalReconciliationAutomationApplyIntent>;
}
export function buildLocalReconciliationAutomationApplyReceipt(
input: Omit<
LocalReconciliationAutomationApplyReceipt,
'schema' | 'schemaVersion' | 'state' | 'applyDigest'
>,
): Readonly<LocalReconciliationAutomationApplyReceipt> {
const payload = Object.freeze({
schema: RECEIPT_SCHEMA,
schemaVersion: 1 as const,
state: 'reconciliation_automation_applied' as const,
...input,
});
return Object.freeze({ ...payload, applyDigest: cutoverDigest(payload) });
}
export function normalizeLocalReconciliationAutomationApplyReceipt(
value: unknown,
): Readonly<LocalReconciliationAutomationApplyReceipt> {
const selected = exact(
value,
[
'adoptedTaskCount',
'adoptedTriggerCount',
'appliedAtMs',
'applyDigest',
'automationId',
'decisionId',
'mutationId',
'preparationDigest',
'preparedHeadDigest',
'publicationDigest',
'schema',
'schemaVersion',
'skippedCount',
'state',
'targetAfter',
],
'receipt',
);
const { applyDigest, ...payload } = selected;
const targetAfter = snapshot(selected.targetAfter, 'targetAfter');
if (
selected.schema !== RECEIPT_SCHEMA ||
selected.schemaVersion !== 1 ||
selected.state !== 'reconciliation_automation_applied' ||
!validDigest(applyDigest) ||
cutoverDigest(payload) !== applyDigest ||
![
selected.preparationDigest,
selected.preparedHeadDigest,
selected.publicationDigest,
].every(validDigest) ||
![
selected.adoptedTaskCount,
selected.adoptedTriggerCount,
selected.skippedCount,
selected.appliedAtMs,
].every((item) => Number.isSafeInteger(item) && (item as number) >= 0)
)
fail('receipt binding is invalid');
return Object.freeze({
...payload,
targetAfter,
applyDigest,
}) as unknown as Readonly<LocalReconciliationAutomationApplyReceipt>;
}
export function buildLocalReconciliationAutomationRollbackReceipt(
input: Omit<
LocalReconciliationAutomationRollbackReceipt,
'schema' | 'schemaVersion' | 'state' | 'rollbackDigest'
>,
): Readonly<LocalReconciliationAutomationRollbackReceipt> {
const payload = Object.freeze({
schema: ROLLBACK_SCHEMA,
schemaVersion: 1 as const,
state: 'reconciliation_automation_rolled_back' as const,
...input,
});
return Object.freeze({ ...payload, rollbackDigest: cutoverDigest(payload) });
}
export function normalizeLocalReconciliationAutomationRollbackReceipt(
value: unknown,
): Readonly<LocalReconciliationAutomationRollbackReceipt> {
const selected = exact(
value,
[
'applyDigest',
'automationId',
'decisionId',
'restored',
'rollbackDigest',
'rolledBackAtMs',
'schema',
'schemaVersion',
'state',
],
'rollback receipt',
);
const { rollbackDigest, ...payload } = selected;
const restored = snapshot(selected.restored, 'restored');
if (
selected.schema !== ROLLBACK_SCHEMA ||
selected.schemaVersion !== 1 ||
selected.state !== 'reconciliation_automation_rolled_back' ||
!validDigest(selected.applyDigest) ||
!validDigest(rollbackDigest) ||
cutoverDigest(payload) !== rollbackDigest ||
!Number.isSafeInteger(selected.rolledBackAtMs) ||
(selected.rolledBackAtMs as number) < 0
)
fail('rollback receipt binding is invalid');
return Object.freeze({
...payload,
restored,
rollbackDigest,
}) as unknown as Readonly<LocalReconciliationAutomationRollbackReceipt>;
}
export function localReconciliationAutomationApplyEvidenceContents(
value:
| LocalReconciliationAutomationApplyIntent
| LocalReconciliationAutomationApplyReceipt
| LocalReconciliationAutomationRollbackReceipt,
): string {
return `${JSON.stringify(value, null, 2)}\n`;
}
@@ -13,6 +13,7 @@ import {
type AuthenticatedLocalCommand,
} from '@qinglong/local-owner-console/authenticated-command';
import { openLocalSqliteAuthenticationReadDatabase } from '@qinglong/local-sqlite/authentication-read';
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
import { currentIdentity } from '../../../foundation/contract';
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
@@ -103,6 +104,15 @@ export interface LocalReconciliationAutomationDecisionDependencies
readonly afterHeadAdvanced?: () => void;
}
export interface LocalReconciliationAutomationDecisionTerminal {
readonly intent: Readonly<LocalReconciliationAutomationDecisionIntent>;
readonly receipt: Readonly<LocalReconciliationAutomationDecisionReceipt>;
readonly authorizationPath: string;
readonly context: Readonly<DecisionContext>;
readonly plan: ReturnType<typeof readLocalReconciliationPlanTerminal>;
readonly reviewer: Readonly<SecurityPrincipal>;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(
`reconciliation automation decision ${message}`,
@@ -149,17 +159,17 @@ function validateDirectory(
return mode;
}
function validateCatalog(selected: Readonly<DecisionPaths>, terminal: boolean): void {
function validateCatalog(
selected: Readonly<DecisionPaths>,
terminal: boolean,
): void {
const allowed = new Set([
'authorization.ndjson',
'intent.json',
'receipt.json',
'staging',
...(!terminal
? [
'.intent.json.ql3-deploy-stage',
'.receipt.json.ql3-deploy-stage',
]
? ['.intent.json.ql3-deploy-stage', '.receipt.json.ql3-deploy-stage']
: []),
]);
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
@@ -242,7 +252,9 @@ function terminalJson(
}
async function context(
options: Readonly<LocalReconciliationAutomationDecisionPrepareCommand['options']>,
options: Readonly<
LocalReconciliationAutomationDecisionPrepareCommand['options']
>,
automationId: string,
uid: number,
): Promise<Readonly<DecisionContext>> {
@@ -412,7 +424,7 @@ export async function prepareLocalReconciliationAutomationDecision(
);
if (
current.automation.receipt.automationPlanDigest !==
command.request.expectedAutomationPlanDigest
command.request.expectedAutomationPlanDigest
) {
configurationError('expected automation plan digest drifted');
}
@@ -438,7 +450,8 @@ export async function prepareLocalReconciliationAutomationDecision(
if (
(head.state === 'reconciliation_automation_planned' &&
(head.headDigest !== command.request.expectedHeadDigest ||
head.sourceRecordDigest !== intent.command.request.expectedAutomationPlanDigest)) ||
head.sourceRecordDigest !==
intent.command.request.expectedAutomationPlanDigest)) ||
(head.state === 'reconciliation_automation_decision_prepared' &&
head.sourceRecordDigest !== intent.preparationDigest) ||
(head.state !== 'reconciliation_automation_planned' &&
@@ -453,7 +466,8 @@ export async function prepareLocalReconciliationAutomationDecision(
ensurePrivateDirectory(selected.root, uid, 'automationDecisionDirectory');
ensurePrivateDirectory(selected.staging, uid, 'automationDecisionStaging');
validateCatalog(selected, false);
const contents = localReconciliationAutomationDecisionEvidenceContents(intent);
const contents =
localReconciliationAutomationDecisionEvidenceContents(intent);
preflightPublishedFile(
selected.intent,
contents,
@@ -505,7 +519,9 @@ function validateCommitBinding(
function strongReviewer(
authenticated: Readonly<AuthenticatedLocalCommand>,
original: Readonly<DecisionContext['application']['review']['authorization']['header']['reviewer']>,
original: Readonly<
DecisionContext['application']['review']['authorization']['header']['reviewer']
>,
committedAtMs: number,
) {
const principal = authenticated.principal;
@@ -598,15 +614,16 @@ async function authorization(
);
if (
capture.command.request.targetDatabasePath !==
command.options.targetDatabasePath
command.options.targetDatabasePath
) {
configurationError('authentication database is detached from capture');
}
const openRequirements = createLocalReconciliationAutomationRequirementFactory(
selected.automation.planPath,
selected.automation.receipt,
uid,
);
const openRequirements =
createLocalReconciliationAutomationRequirementFactory(
selected.automation.planPath,
selected.automation.receipt,
uid,
);
const keyringPath =
selected.application.intent.command.options.issuerKeyringPath;
ensureLocalReconciliationReviewIssuerKeyring(keyringPath);
@@ -774,11 +791,12 @@ async function verifyPublication(
allowedParentModes: readonly (0o500 | 0o700)[],
): Promise<Readonly<ReconciliationAutomationDecisionPublication>> {
const terminal = planTerminal(selected);
const openRequirements = createLocalReconciliationAutomationRequirementFactory(
selected.automation.planPath,
selected.automation.receipt,
uid,
);
const openRequirements =
createLocalReconciliationAutomationRequirementFactory(
selected.automation.planPath,
selected.automation.receipt,
uid,
);
const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider(
selected.application.intent.command.options.issuerKeyringPath,
);
@@ -862,8 +880,7 @@ export async function commitLocalReconciliationAutomationDecision(
receipt.preparedHeadDigest !== command.request.expectedHeadDigest ||
receipt.issuedAtMs !== command.request.committedAtMs ||
receipt.expiresAtMs !==
command.request.committedAtMs +
command.request.authorizationLifetimeMs
command.request.committedAtMs + command.request.authorizationLifetimeMs
) {
configurationError('terminal receipt is not an exact command replay');
}
@@ -1013,6 +1030,58 @@ export async function verifyLocalReconciliationAutomationDecision(
return terminalResult(command.operation, 'verified', receipt, head);
}
export async function readLocalReconciliationAutomationDecisionTerminal(
options: Readonly<
LocalReconciliationAutomationDecisionPrepareCommand['options']
>,
automationId: string,
uid: number,
dependencies: LocalReconciliationAutomationDecisionDependencies = {},
): Promise<Readonly<LocalReconciliationAutomationDecisionTerminal>> {
for (const [directory, label] of [
[options.deploymentRoot, 'deploymentRoot'],
[options.applicationRoot, 'applicationRoot'],
[options.automationRoot, 'automationRoot'],
[options.automationDecisionRoot, 'automationDecisionRoot'],
] as const) {
validatePrivateDirectory(directory, uid, label);
}
const selectedPaths = paths(options.automationDecisionRoot, automationId);
validateDirectory(selectedPaths.root, uid, [0o500], 'decision root');
validateDirectory(selectedPaths.staging, uid, [0o500], 'decision staging');
validateCatalog(selectedPaths, true);
const intent = readIntent(selectedPaths, uid, [0o400]);
const receipt = readReceipt(selectedPaths, uid, [0o400]);
if (
intent.command.request.automationId !== automationId ||
receipt.automationId !== automationId ||
receipt.decisionId !== intent.command.request.decisionId
) {
configurationError('terminal decision identity drifted');
}
const selected = await context(options, automationId, uid);
intentBinding(intent, selected);
const publication = await verifyPublication(
selectedPaths,
intent,
selected,
receipt,
dependencies,
uid,
[0o400],
[0o500],
);
validateReceiptBinding(receipt, intent, publication);
return Object.freeze({
intent,
receipt,
authorizationPath: selectedPaths.authorization,
context: selected,
plan: planTerminal(selected),
reviewer: publication.authorization.receipt.reviewer,
});
}
export function prepareLocalReconciliationAutomationDecisionCommandFile(
filePath: string,
dependencies: LocalReconciliationAutomationDecisionDependencies = {},
@@ -11,17 +11,21 @@ const {
commitLocalReconciliationCapture,
commitLocalReconciliationApplication,
commitLocalReconciliationAutomationDecision,
applyLocalReconciliationAutomation,
commitLocalReconciliationPlan,
commitLocalReconciliationReview,
prepareLocalReconciliationCapture,
prepareLocalReconciliationApplication,
prepareLocalReconciliationAutomationDecision,
readLocalReconciliationAutomationDecisionTerminal,
rollbackLocalReconciliationAutomationApply,
planLocalReconciliationAutomation,
prepareLocalReconciliationPlan,
prepareLocalReconciliationReview,
verifyLocalReconciliationCapture,
verifyLocalReconciliationApplication,
verifyLocalReconciliationAutomationDecision,
verifyLocalReconciliationAutomationApply,
verifyLocalReconciliationAutomationPlan,
verifyLocalReconciliationPlan,
verifyLocalReconciliationReview,
@@ -703,6 +707,41 @@ function automationDatabaseInitializer() {
};
}
function automationReadyDatabaseInitializer() {
const initializeLegacy = automationDatabaseInitializer();
return (paths) => {
initializeLegacy(paths);
fs.truncateSync(paths.targetDatabasePath, 0);
const migration = spawnSync(
process.execPath,
[
'-e',
`require('@qinglong/local-sqlite/migration')
.migrateLocalSqlitePath({ databasePath: process.argv[1], profile: 'edge' })
.catch((error) => { console.error(error); process.exitCode = 1; });`,
paths.targetDatabasePath,
],
{ encoding: 'utf8', cwd: path.join(__dirname, '..') },
);
assert.equal(migration.status, 0, migration.stderr);
fs.chmodSync(paths.targetDatabasePath, 0o600);
const target = new DatabaseSync(paths.targetDatabasePath);
target.exec(`
INSERT INTO "QingLong3ProjectRoleBindings" (
"project_id", "subject_type", "subject_id", "version", "state",
"role", "mutation_id", "changed_by_type", "changed_by_id",
"created_at_ms"
) VALUES (
'default', 'user', 'review-owner', 1, 'active', 'owner',
'automation-apply-owner-binding', 'user', 'review-owner', 1
);
PRAGMA wal_checkpoint(TRUNCATE);
PRAGMA journal_mode=DELETE;
`);
target.close();
};
}
function mutateAutomationTarget({ targetDatabasePath }, occupied = false) {
const target = new DatabaseSync(targetDatabasePath);
if (occupied) {
@@ -719,6 +758,13 @@ function mutateAutomationTarget({ targetDatabasePath }, occupied = false) {
return Object.freeze({});
}
function mutateReadyAutomationTarget({ targetDatabasePath }) {
const target = new DatabaseSync(targetDatabasePath);
target.exec('PRAGMA user_version=1');
target.close();
return Object.freeze({});
}
function mutatePlanningTarget({ targetDatabasePath }) {
const target = new DatabaseSync(targetDatabasePath);
target.exec('INSERT INTO "QingLong3TaskDefinitions" (id) VALUES (1)');
@@ -1080,9 +1126,14 @@ async function plannedAutomationFixture(t, options = {}) {
applicationId: options.applicationId,
reviewSuffix: `automation-decision-${suffix}`,
createDefaultSidecars: false,
initializeDatabases: automationDatabaseInitializer(),
initializeDatabases:
options.readyTarget === true
? automationReadyDatabaseInitializer()
: automationDatabaseInitializer(),
mutateTarget(paths) {
return mutateAutomationTarget(paths, options.occupied === true);
return options.readyTarget === true
? mutateReadyAutomationTarget(paths)
: mutateAutomationTarget(paths, options.occupied === true);
},
mutateDecisions(records) {
const selected = records.find(
@@ -1094,8 +1145,10 @@ async function plannedAutomationFixture(t, options = {}) {
record.disposition === 'exclude_legacy',
);
assert.ok(selected);
selected.disposition = options.occupied === true ? 'retain_both' : 'adopt_legacy';
selected.reason = options.occupied === true ? 'preserve_both' : 'prefer_legacy';
selected.disposition =
options.occupied === true ? 'retain_both' : 'adopt_legacy';
selected.reason =
options.occupied === true ? 'preserve_both' : 'prefer_legacy';
},
});
const preparedApplication = await prepareLocalReconciliationApplication(
@@ -1242,7 +1295,8 @@ function automationDecisionCommitFixture(
options: {
...prepared.commandOptions,
targetDatabasePath: state.targetDatabasePath,
ownerPepperKeyringDirectory: state.command.options.ownerPepperKeyringDirectory,
ownerPepperKeyringDirectory:
state.command.options.ownerPepperKeyringDirectory,
credentialFilePath: state.command.options.credentialFilePath,
},
request: {
@@ -2885,7 +2939,10 @@ test('automation adapter builds a sealed row plan with bounded conflict evidence
const application = await commitLocalReconciliationApplication(
applicationCommitCommand(state, prepared),
);
const automationRoot = path.join(path.dirname(state.captureRoot), 'automation-root');
const automationRoot = path.join(
path.dirname(state.captureRoot),
'automation-root',
);
fs.mkdirSync(automationRoot, { mode: 0o700 });
const targetBefore = fs.readFileSync(state.targetDatabasePath);
const command = {
@@ -2944,7 +3001,10 @@ test('automation adapter builds a sealed row plan with bounded conflict evidence
assert.equal(planText.includes('legacy-cron:1'), true);
assert.equal(planText.includes('review-owner'), false);
assert.equal(planText.includes('"requirement":"review_adopt"'), true);
assert.equal(fs.readFileSync(state.targetDatabasePath).equals(targetBefore), true);
assert.equal(
fs.readFileSync(state.targetDatabasePath).equals(targetBefore),
true,
);
const replay = await planLocalReconciliationAutomation(command);
assert.equal(replay.status, 'existing');
@@ -2994,6 +3054,7 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
reviewId: '00000000-0000-4000-8000-000000000462',
applicationId: '00000000-0000-4000-8000-000000000463',
automationId: '00000000-0000-4000-8000-000000000464',
readyTarget: true,
});
assert.equal(state.planRows[0].requirement, 'review_adopt');
const decisionId = '019b0000-0000-7000-8000-000000000461';
@@ -3067,11 +3128,28 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
verifyCommand,
);
assert.equal(verified.status, 'verified');
assert.equal(verified.signedDecisionSetDigest, committed.signedDecisionSetDigest);
assert.equal(
verified.signedDecisionSetDigest,
committed.signedDecisionSetDigest,
);
const serialized = JSON.stringify(verified);
assert.equal(serialized.includes('review-owner'), false);
assert.equal(serialized.includes(state.planRows[0].sourceDigest), false);
assert.equal(serialized.includes(review.filePath), false);
const terminal = await readLocalReconciliationAutomationDecisionTerminal(
prepareCommand.options,
state.automationCommand.request.automationId,
process.getuid(),
);
assert.equal(terminal.receipt.decisionDigest, committed.decisionDigest);
assert.equal(
terminal.context.application.plan.applicationPlanDigest,
state.application.applicationPlanDigest,
);
assert.equal(
terminal.authorizationPath.endsWith('/authorization.ndjson'),
true,
);
const commandPath = path.join(
state.deploymentRoot,
'automation-decision-verify.json',
@@ -3093,6 +3171,249 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
assert.equal(JSON.parse(cli.stdout).status, 'verified');
assert.equal(cli.stdout.includes('review-owner'), false);
assert.equal(cli.stdout.includes(state.planRows[0].sourceDigest), false);
const automationApplyRoot = path.join(
path.dirname(state.captureRoot),
'automation-apply-signed-success',
);
fs.mkdirSync(automationApplyRoot, { mode: 0o700 });
const appliedAtMs = commit.command.request.committedAtMs + 1;
const targetIdentity = fs.statSync(state.targetDatabasePath);
const applyOptions = {
...prepareCommand.options,
automationApplyRoot,
targetDatabasePath: state.targetDatabasePath,
ownerPepperKeyringDirectory:
state.command.options.ownerPepperKeyringDirectory,
credentialFilePath: state.command.options.credentialFilePath,
};
const applyCommand = {
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.apply',
options: applyOptions,
request: {
decisionId,
automationId: state.automationCommand.request.automationId,
expectedDecisionDigest: committed.decisionDigest,
expectedHeadDigest: committed.instanceHeadDigest,
mutationId: '00000000-0000-4000-8000-000000000465',
requestId: 'automation-apply-signed-success',
appliedAtMs,
},
};
const applyDependencies = {
async openAuthenticationDatabase() {
return { async close() {} };
},
async authenticate(_database, options) {
assert.equal(
options.authenticationNamespace,
'local_reconciliation_automation_apply',
);
const authenticatedAtMs = options.now();
return {
principal: {
subject: { type: 'user', id: 'review-owner' },
authenticationId: 'local_reconciliation_automation_apply:test',
authenticatedAtMs,
expiresAtMs: authenticatedAtMs + 60 * 60 * 1_000,
assurance: 'local_console',
},
databaseFence: {
credentialId: 'review-owner',
credentialVersion: 1,
pepperKeyId: 'review-owner-v1',
pepperVersion: 1,
},
async confirm() {},
};
},
};
const liveApplyEvidence = readTargetDataReconciliationEvidenceForPaths(
{
profile: state.captureCommand.request.profile,
activationPath: state.captureCommand.request.activationPath,
legacySourcePath: state.captureCommand.request.legacySourcePath,
targetDatabasePath: state.targetDatabasePath,
expectedActivationDigest:
state.captureCommand.request.expectedActivationDigest,
},
process.getuid(),
);
assert.equal(
liveApplyEvidence.disposition,
'reconciliation_required',
JSON.stringify({
liveApplyEvidence,
activation: JSON.parse(
fs.readFileSync(state.captureCommand.request.activationPath, 'utf8'),
),
target: {
...fs.statSync(state.targetDatabasePath),
mode: fs.statSync(state.targetDatabasePath).mode & 0o777,
realpath: fs.realpathSync(state.targetDatabasePath),
sidecars: ['-wal', '-shm', '-journal'].map((suffix) =>
fs.existsSync(`${state.targetDatabasePath}${suffix}`),
),
},
}),
);
for (const boundary of ['afterBackupPublished', 'afterPreparedHead']) {
await assert.rejects(
applyLocalReconciliationAutomation(applyCommand, {
...applyDependencies,
[boundary]() {
throw new Error(`automation apply ${boundary} response loss`);
},
}),
new RegExp(`automation apply ${boundary} response loss`),
);
}
await assert.rejects(
applyLocalReconciliationAutomation(applyCommand, {
...applyDependencies,
async authenticate(database, options) {
const authenticated = await applyDependencies.authenticate(
database,
options,
);
return {
...authenticated,
principal: {
...authenticated.principal,
subject: { type: 'user', id: 'another-owner' },
},
};
},
}),
/current reviewer authentication is not strong or identical/,
);
for (const boundary of ['afterDatabaseCommit', 'afterReceiptPublished']) {
await assert.rejects(
applyLocalReconciliationAutomation(applyCommand, {
...applyDependencies,
[boundary]() {
throw new Error(`automation apply ${boundary} response loss`);
},
}),
new RegExp(`automation apply ${boundary} response loss`),
);
}
const applied = await applyLocalReconciliationAutomation(
applyCommand,
applyDependencies,
);
assert.equal(applied.status, 'existing');
assert.equal(applied.state, 'reconciliation_automation_applied');
assert.equal(applied.adoptedTaskCount, 1);
assert.equal(fs.statSync(state.targetDatabasePath).ino, targetIdentity.ino);
const applyReplay = await applyLocalReconciliationAutomation(
applyCommand,
applyDependencies,
);
assert.equal(applyReplay.status, 'existing');
const applyVerified = await verifyLocalReconciliationAutomationApply({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.apply.verify',
options: applyOptions,
request: {
decisionId,
automationId: state.automationCommand.request.automationId,
expectedApplyDigest: applied.applyDigest,
},
});
assert.equal(applyVerified.status, 'verified');
const rollbackCommand = {
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.apply.rollback',
options: applyOptions,
request: {
decisionId,
automationId: state.automationCommand.request.automationId,
expectedApplyDigest: applied.applyDigest,
expectedHeadDigest: applied.instanceHeadDigest,
rolledBackAtMs: appliedAtMs + 1,
},
};
for (const boundary of ['afterRestore', 'afterRollbackReceipt']) {
await assert.rejects(
rollbackLocalReconciliationAutomationApply(rollbackCommand, {
...applyDependencies,
[boundary]() {
throw new Error(`automation rollback ${boundary} response loss`);
},
}),
new RegExp(`automation rollback ${boundary} response loss`),
);
}
const rolledBack = await rollbackLocalReconciliationAutomationApply(
rollbackCommand,
applyDependencies,
);
assert.equal(rolledBack.status, 'existing');
assert.equal(rolledBack.state, 'reconciliation_automation_rolled_back');
assert.equal(fs.statSync(state.targetDatabasePath).ino, targetIdentity.ino);
assert.equal(
(
await rollbackLocalReconciliationAutomationApply(
rollbackCommand,
applyDependencies,
)
).status,
'existing',
);
const rollbackVerified = await verifyLocalReconciliationAutomationApply({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.apply.verify',
options: applyOptions,
request: {
decisionId,
automationId: state.automationCommand.request.automationId,
expectedApplyDigest: applied.applyDigest,
},
});
assert.equal(rollbackVerified.status, 'verified');
assert.equal(rollbackVerified.state, 'reconciliation_automation_rolled_back');
const applyVerifyPath = path.join(
state.deploymentRoot,
'automation-apply-verify.json',
);
fs.writeFileSync(
applyVerifyPath,
`${JSON.stringify({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.apply.verify',
options: applyOptions,
request: {
decisionId,
automationId: state.automationCommand.request.automationId,
expectedApplyDigest: applied.applyDigest,
},
})}\n`,
{ mode: 0o600 },
);
const applyCli = spawnSync(
process.execPath,
[
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
'reconciliation-automation-apply-verify',
'--command-file',
applyVerifyPath,
],
{ encoding: 'utf8' },
);
assert.equal(applyCli.status, 0, applyCli.stderr);
assert.equal(JSON.parse(applyCli.stdout).status, 'verified');
const restored = new DatabaseSync(state.targetDatabasePath, {
readOnly: true,
});
assert.equal(
restored
.prepare('SELECT COUNT(*) AS count FROM "QingLong3LegacyAdoptions"')
.get().count,
0,
);
restored.close();
});
test('automation decision rejects conflict adoption, another reviewer and weak assurance', async (t) => {
@@ -3294,8 +3615,7 @@ test('automation decision verification rejects sealed authorization and plan dri
suffix: 'authorization-drift',
automationId: '00000000-0000-4000-8000-000000000475',
});
const authorizationDecisionId =
'019b0000-0000-7000-8000-000000000475';
const authorizationDecisionId = '019b0000-0000-7000-8000-000000000475';
const authorizationReview = automationDecisionReviewFile(
authorizationState,
authorizationDecisionId,
@@ -3483,7 +3803,9 @@ test('automation row plan fails closed to manual review on a target task collisi
});
test('automation row planner makes an empty table no-effect and a missing timezone manual', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-automation-row-unit-'));
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-automation-row-unit-'),
);
t.after(() => removeFixtureRoot(root));
const legacySourcePath = path.join(root, 'legacy.sqlite');
const recoveryPath = path.join(root, 'recovery.sqlite');
@@ -3548,7 +3870,10 @@ test('automation row planner makes an empty table no-effect and a missing timezo
manual = writeLocalReconciliationAutomationPlan({
descriptor: manualDescriptor,
maxBytes: 64 * 1024,
header: { ...header, automationId: '00000000-0000-4000-8000-00000000043b' },
header: {
...header,
automationId: '00000000-0000-4000-8000-00000000043b',
},
legacy,
target,
});
@@ -3639,7 +3964,13 @@ test('automation row planning replays every publication boundary and rejects dri
['seal', '000000000449', '00000000044a', '00000000044b', '00000000044c'],
['head', '00000000044d', '00000000044e', '00000000044f', '000000000450'],
];
for (const [window, planTail, reviewTail, applicationTail, automationTail] of windows) {
for (const [
window,
planTail,
reviewTail,
applicationTail,
automationTail,
] of windows) {
const state = await reviewedApplicationFixture(t, {
planId: `00000000-0000-4000-8000-${planTail}`,
reviewId: `00000000-0000-4000-8000-${reviewTail}`,
+2 -2
View File
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
},
{
sourceFiles: 165,
sourceFiles: 168,
rootSourceFiles: 1,
rootSourceLines: 50,
nestedSourceFiles: 164,
nestedSourceFiles: 167,
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
},
);