feat(ql3): plan reconciliation application

This commit is contained in:
whyour
2026-08-21 20:24:41 +08:00
parent 22f58b1eff
commit c218f71250
14 changed files with 2634 additions and 5 deletions
@@ -29,6 +29,8 @@ export type LocalCutoverInstanceHeadState =
| 'reconciliation_planned'
| 'reconciliation_review_prepared'
| 'reconciliation_reviewed'
| 'reconciliation_application_prepared'
| 'reconciliation_application_planned'
| 'rollback_prepared'
| 'legacy_restart_requested'
| 'legacy_running'
@@ -166,6 +168,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
head.state !== 'reconciliation_planned' &&
head.state !== 'reconciliation_review_prepared' &&
head.state !== 'reconciliation_reviewed' &&
head.state !== 'reconciliation_application_prepared' &&
head.state !== 'reconciliation_application_planned' &&
head.state !== 'rollback_prepared' &&
head.state !== 'legacy_restart_requested' &&
head.state !== 'legacy_running' &&
@@ -338,6 +342,8 @@ export function advanceLocalCutoverInstanceHead(
| 'reconciliation_planned'
| 'reconciliation_review_prepared'
| 'reconciliation_reviewed'
| 'reconciliation_application_prepared'
| 'reconciliation_application_planned'
| 'rollback_prepared'
| 'legacy_restart_requested'
| 'legacy_running'
@@ -382,6 +388,8 @@ export function advanceLocalCutoverInstanceHead(
current.state === 'reconciliation_planned' ||
current.state === 'reconciliation_review_prepared' ||
current.state === 'reconciliation_reviewed' ||
current.state === 'reconciliation_application_prepared' ||
current.state === 'reconciliation_application_planned' ||
current.state === 'legacy_restart_requested' ||
current.state === 'legacy_running' ||
current.state === 'legacy_ready')
@@ -409,6 +417,10 @@ export function advanceLocalCutoverInstanceHead(
current.state === 'reconciliation_planned') ||
(state === 'reconciliation_reviewed' &&
current.state === 'reconciliation_review_prepared') ||
(state === 'reconciliation_application_prepared' &&
current.state === 'reconciliation_reviewed') ||
(state === 'reconciliation_application_planned' &&
current.state === 'reconciliation_application_prepared') ||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
(state === 'legacy_restart_requested' &&
current.state === 'rollback_prepared') ||
@@ -123,6 +123,14 @@ import {
verifyLocalReconciliationReview,
verifyLocalReconciliationReviewCommandFile,
} from './reconciliation/review/completion';
import {
commitLocalReconciliationApplication,
commitLocalReconciliationApplicationCommandFile,
prepareLocalReconciliationApplication,
prepareLocalReconciliationApplicationCommandFile,
verifyLocalReconciliationApplication,
verifyLocalReconciliationApplicationCommandFile,
} from './reconciliation/application/coordinator';
export {
commitLocalReconciliationPlan,
@@ -139,8 +147,42 @@ export {
commitLocalReconciliationReviewCommandFile,
verifyLocalReconciliationReview,
verifyLocalReconciliationReviewCommandFile,
prepareLocalReconciliationApplication,
prepareLocalReconciliationApplicationCommandFile,
commitLocalReconciliationApplication,
commitLocalReconciliationApplicationCommandFile,
verifyLocalReconciliationApplication,
verifyLocalReconciliationApplicationCommandFile,
};
export {
normalizeLocalReconciliationApplicationCommitCommand,
normalizeLocalReconciliationApplicationPrepareCommand,
normalizeLocalReconciliationApplicationVerifyCommand,
type LocalReconciliationApplicationCommitCommand,
type LocalReconciliationApplicationOptions,
type LocalReconciliationApplicationPrepareCommand,
type LocalReconciliationApplicationPrepareResult,
type LocalReconciliationApplicationTerminalResult,
type LocalReconciliationApplicationVerifyCommand,
} from './reconciliation/application/contract';
export {
localReconciliationApplicationDirectory,
normalizeLocalReconciliationApplicationIntent,
readLocalReconciliationApplicationIntent,
type LocalReconciliationApplicationDependencies,
type LocalReconciliationApplicationIntent,
} from './reconciliation/application/coordinator';
export {
normalizeLocalReconciliationApplicationPlan,
normalizeLocalReconciliationApplicationPlanReceipt,
type LocalReconciliationApplicationDatabaseDecisionSummary,
type LocalReconciliationApplicationDomainAction,
type LocalReconciliationApplicationDomainSummary,
type LocalReconciliationApplicationPlan,
type LocalReconciliationApplicationPlanReceipt,
} from './reconciliation/application/plan';
export {
LocalDeploymentConfigurationError,
normalizeLocalDeploymentComposeApplyCommand,
@@ -22,6 +22,9 @@ import {
verifyLocalReconciliationCaptureCommandFile,
verifyLocalReconciliationPlanCommandFile,
verifyLocalReconciliationReviewCommandFile,
prepareLocalReconciliationApplicationCommandFile,
commitLocalReconciliationApplicationCommandFile,
verifyLocalReconciliationApplicationCommandFile,
writeLocalReconciliationReviewDiagnosticsCommandFile,
prepareLocalDeploymentCommandFile,
proveLocalDeploymentLegacyReadinessCommandFile,
@@ -37,7 +40,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|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|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')) {
@@ -76,6 +79,9 @@ async function main(argv: readonly string[]): Promise<void> {
argv[0] !== 'reconciliation-review-diagnostics' &&
argv[0] !== 'reconciliation-review-commit' &&
argv[0] !== 'reconciliation-review-verify' &&
argv[0] !== 'reconciliation-application-prepare' &&
argv[0] !== 'reconciliation-application-commit' &&
argv[0] !== 'reconciliation-application-verify' &&
argv[0] !== 'compose-revision' &&
argv[0] !== 'compose-preflight' &&
argv[0] !== 'compose-apply' &&
@@ -166,6 +172,12 @@ async function main(argv: readonly string[]): Promise<void> {
? commitLocalReconciliationReviewCommandFile(argv[2]!)
: argv[0] === 'reconciliation-review-verify'
? verifyLocalReconciliationReviewCommandFile(argv[2]!)
: argv[0] === 'reconciliation-application-prepare'
? prepareLocalReconciliationApplicationCommandFile(argv[2]!)
: argv[0] === 'reconciliation-application-commit'
? commitLocalReconciliationApplicationCommandFile(argv[2]!)
: argv[0] === 'reconciliation-application-verify'
? verifyLocalReconciliationApplicationCommandFile(argv[2]!)
: argv[0] === 'compose-revision'
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
: argv[0] === 'compose-preflight'
@@ -0,0 +1,372 @@
import path from 'node:path';
import { currentIdentity } from '../../foundation/contract';
import { LocalDeploymentConfigurationError } from '../../foundation/error';
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 interface LocalReconciliationApplicationOptions {
readonly deploymentRoot: string;
readonly captureRoot: string;
readonly planRoot: string;
readonly reviewRoot: string;
readonly applicationRoot: string;
readonly issuerKeyringPath: string;
readonly allowRootService: boolean;
}
export interface LocalReconciliationApplicationPrepareCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.application.prepare';
readonly options: Readonly<LocalReconciliationApplicationOptions>;
readonly request: Readonly<{
applicationId: string;
reviewId: string;
expectedReviewDigest: string;
expectedHeadDigest: string;
preparedAtMs: number;
}>;
}
export interface LocalReconciliationApplicationCommitCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.application.commit';
readonly options: Readonly<LocalReconciliationApplicationOptions>;
readonly request: Readonly<{
applicationId: string;
expectedPreparationDigest: string;
expectedHeadDigest: string;
committedAtMs: number;
}>;
}
export interface LocalReconciliationApplicationVerifyCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.application.verify';
readonly options: Readonly<LocalReconciliationApplicationOptions>;
readonly request: Readonly<{
applicationId: string;
expectedApplicationPlanDigest: string;
}>;
}
export interface LocalReconciliationApplicationPrepareResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.application.prepare';
readonly status: 'prepared' | 'existing';
readonly state: 'reconciliation_application_prepared';
readonly applicationId: string;
readonly preparationDigest: string;
readonly instanceHeadDigest: string;
}
export interface LocalReconciliationApplicationTerminalResult {
readonly schemaVersion: 1;
readonly operation:
| 'local.deployment.reconciliation.application.commit'
| 'local.deployment.reconciliation.application.verify';
readonly status: 'prepared' | 'existing' | 'verified';
readonly state: 'reconciliation_application_planned';
readonly applicationId: string;
readonly applicationPlanDigest: string;
readonly outcome:
| 'no_effect_ready'
| 'adapter_required'
| 'manual_required'
| 'adapter_and_manual_required';
readonly domainCount: 8;
readonly instanceHeadDigest: string;
}
function configurationError(message: string): never {
throw new LocalDeploymentConfigurationError(message);
}
function object(value: unknown, label: string): Record<string, unknown> {
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<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, index) => key !== expected[index])
) {
configurationError(`${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_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 descendant(root: string, candidate: string, label: string): void {
const relative = path.relative(root, candidate);
if (
relative.length === 0 ||
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
configurationError(`${label} must be a descendant of deploymentRoot`);
}
}
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<LocalReconciliationApplicationOptions> {
const options = object(value, 'options');
exact(
options,
[
'allowRootService',
'applicationRoot',
'captureRoot',
'deploymentRoot',
'issuerKeyringPath',
'planRoot',
'reviewRoot',
],
'options',
);
const identity = currentIdentity();
if (
typeof options.allowRootService !== 'boolean' ||
(identity.uid === 0) !== options.allowRootService
) {
configurationError(
'reconciliation application command identity is invalid',
);
}
const roots = [
safePath(options.deploymentRoot, 'deploymentRoot'),
safePath(options.captureRoot, 'captureRoot'),
safePath(options.planRoot, 'planRoot'),
safePath(options.reviewRoot, 'reviewRoot'),
safePath(options.applicationRoot, 'applicationRoot'),
];
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, review and application roots must not overlap',
);
}
}
}
const issuerKeyringPath = safePath(
options.issuerKeyringPath,
'issuerKeyringPath',
);
descendant(roots[0]!, issuerKeyringPath, 'issuerKeyringPath');
return Object.freeze({
deploymentRoot: roots[0]!,
captureRoot: roots[1]!,
planRoot: roots[2]!,
reviewRoot: roots[3]!,
applicationRoot: roots[4]!,
issuerKeyringPath,
allowRootService: options.allowRootService,
});
}
function command(
value: unknown,
operation:
| LocalReconciliationApplicationPrepareCommand['operation']
| LocalReconciliationApplicationCommitCommand['operation']
| LocalReconciliationApplicationVerifyCommand['operation'],
): Readonly<{
options: Readonly<LocalReconciliationApplicationOptions>;
request: Record<string, unknown>;
}> {
const selected = object(value, 'reconciliation application command');
exact(
selected,
['operation', 'options', 'request', 'schemaVersion'],
'command',
);
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
configurationError('reconciliation application command is invalid');
}
return Object.freeze({
options: normalizeOptions(selected.options),
request: object(selected.request, 'request'),
});
}
export function normalizeLocalReconciliationApplicationPrepareCommand(
value: unknown,
): Readonly<LocalReconciliationApplicationPrepareCommand> {
const selected = command(
value,
'local.deployment.reconciliation.application.prepare',
);
exact(
selected.request,
[
'applicationId',
'expectedHeadDigest',
'expectedReviewDigest',
'preparedAtMs',
'reviewId',
],
'request',
);
if (
!Number.isSafeInteger(selected.request.preparedAtMs) ||
(selected.request.preparedAtMs as number) < 0
) {
configurationError('reconciliation application prepare command is invalid');
}
return Object.freeze({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.application.prepare',
options: selected.options,
request: Object.freeze({
applicationId: identifier(
selected.request.applicationId,
'applicationId',
),
reviewId: identifier(selected.request.reviewId, 'reviewId'),
expectedReviewDigest: digest(
selected.request.expectedReviewDigest,
'expectedReviewDigest',
),
expectedHeadDigest: digest(
selected.request.expectedHeadDigest,
'expectedHeadDigest',
),
preparedAtMs: selected.request.preparedAtMs as number,
}),
});
}
export function normalizeLocalReconciliationApplicationCommitCommand(
value: unknown,
): Readonly<LocalReconciliationApplicationCommitCommand> {
const selected = command(
value,
'local.deployment.reconciliation.application.commit',
);
exact(
selected.request,
[
'applicationId',
'committedAtMs',
'expectedHeadDigest',
'expectedPreparationDigest',
],
'request',
);
if (
!Number.isSafeInteger(selected.request.committedAtMs) ||
(selected.request.committedAtMs as number) < 0
) {
configurationError('reconciliation application commit command is invalid');
}
return Object.freeze({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.application.commit',
options: selected.options,
request: Object.freeze({
applicationId: identifier(
selected.request.applicationId,
'applicationId',
),
expectedPreparationDigest: digest(
selected.request.expectedPreparationDigest,
'expectedPreparationDigest',
),
expectedHeadDigest: digest(
selected.request.expectedHeadDigest,
'expectedHeadDigest',
),
committedAtMs: selected.request.committedAtMs as number,
}),
});
}
export function normalizeLocalReconciliationApplicationVerifyCommand(
value: unknown,
): Readonly<LocalReconciliationApplicationVerifyCommand> {
const selected = command(
value,
'local.deployment.reconciliation.application.verify',
);
exact(
selected.request,
['applicationId', 'expectedApplicationPlanDigest'],
'request',
);
return Object.freeze({
schemaVersion: 1,
operation: 'local.deployment.reconciliation.application.verify',
options: selected.options,
request: Object.freeze({
applicationId: identifier(
selected.request.applicationId,
'applicationId',
),
expectedApplicationPlanDigest: digest(
selected.request.expectedApplicationPlanDigest,
'expectedApplicationPlanDigest',
),
}),
});
}
@@ -0,0 +1,510 @@
import { LocalDeploymentConfigurationError } from '../../foundation/error';
import { cutoverDigest } from '../../cutover/targetEvidence';
import type {
LocalReconciliationReviewAuthorizationDomainDecisionCounts,
LocalReconciliationReviewAuthorizationEvidence,
} from '../review/authorization';
import {
LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS,
type LocalReconciliationReviewDisposition,
} from '../review/decisionFile';
import {
LOCAL_RECONCILIATION_PLAN_DOMAINS,
type LocalReconciliationPlanDomain,
} from '../planning/contract';
import type { LocalReconciliationApplicationIntent } from './coordinator';
const PLAN_SCHEMA = 'qinglong3-local-reconciliation-application-plan';
const RECEIPT_SCHEMA =
'qinglong3-local-reconciliation-application-plan-receipt';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
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_PLAN_BYTES = 64 * 1024;
export type LocalReconciliationApplicationDomainAction =
| 'no_effect'
| 'adapter_required'
| 'manual_external'
| 'adapter_and_manual';
export interface LocalReconciliationApplicationDatabaseDecisionSummary {
readonly decisionCount: number;
readonly dispositionCounts: Readonly<
Record<LocalReconciliationReviewDisposition, number>
>;
}
export interface LocalReconciliationApplicationDomainSummary {
readonly domain: LocalReconciliationPlanDomain;
readonly legacy: Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
readonly target: Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
readonly action: LocalReconciliationApplicationDomainAction;
readonly summaryDigest: string;
}
export interface LocalReconciliationApplicationPlan {
readonly schema: typeof PLAN_SCHEMA;
readonly schemaVersion: 1;
readonly state: 'reconciliation_application_planned';
readonly applicationId: string;
readonly reviewId: string;
readonly profile: 'edge' | 'standalone';
readonly preparationDigest: string;
readonly preparedHeadDigest: string;
readonly reviewDigest: string;
readonly authorizationDigest: string;
readonly decisionSetDigest: string;
readonly decisionCount: number;
readonly committedAtMs: number;
readonly domains: readonly Readonly<LocalReconciliationApplicationDomainSummary>[];
readonly outcome:
| 'no_effect_ready'
| 'adapter_required'
| 'manual_required'
| 'adapter_and_manual_required';
readonly applicationPlanDigest: string;
}
export interface LocalReconciliationApplicationPlanReceipt {
readonly schema: typeof RECEIPT_SCHEMA;
readonly schemaVersion: 1;
readonly state: 'reconciliation_application_planned';
readonly applicationId: string;
readonly reviewId: string;
readonly preparationDigest: string;
readonly preparedHeadDigest: string;
readonly reviewDigest: string;
readonly authorizationDigest: string;
readonly decisionSetDigest: string;
readonly decisionCount: number;
readonly applicationPlanDigest: string;
readonly outcome: LocalReconciliationApplicationPlan['outcome'];
readonly domainCount: 8;
readonly committedAtMs: number;
readonly receiptDigest: string;
}
function configurationError(message: string): never {
throw new LocalDeploymentConfigurationError(message);
}
function object(value: unknown, label: string): Record<string, unknown> {
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<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, index) => key !== expected[index])
) {
configurationError(`${label} shape is invalid`);
}
}
function safeCount(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function counts(
value: unknown,
label: string,
): Readonly<Record<LocalReconciliationReviewDisposition, number>> {
const selected = object(value, label);
exact(selected, LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS, label);
if (
LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS.some(
(disposition) => !safeCount(selected[disposition]),
)
) {
configurationError(`${label} is invalid`);
}
return Object.freeze(selected) as Readonly<
Record<LocalReconciliationReviewDisposition, number>
>;
}
function sumCounts(
selected: Readonly<Record<LocalReconciliationReviewDisposition, number>>,
): number {
const total = LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS.reduce(
(sum, disposition) => sum + BigInt(selected[disposition]),
0n,
);
if (total > BigInt(Number.MAX_SAFE_INTEGER)) {
configurationError('application decision count overflowed');
}
return Number(total);
}
function databaseSummary(
selected: Readonly<Record<LocalReconciliationReviewDisposition, number>>,
): Readonly<LocalReconciliationApplicationDatabaseDecisionSummary> {
const dispositionCounts = Object.freeze({ ...selected });
return Object.freeze({
decisionCount: sumCounts(dispositionCounts),
dispositionCounts,
});
}
function action(
selected: Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>,
): LocalReconciliationApplicationDomainAction {
const adapter =
selected.legacy.adopt_legacy +
selected.legacy.retain_both +
selected.target.retain_both >
0;
const manual =
selected.legacy.defer +
selected.legacy.manual_external +
selected.target.defer +
selected.target.manual_external >
0;
if (adapter && manual) return 'adapter_and_manual';
if (adapter) return 'adapter_required';
if (manual) return 'manual_external';
return 'no_effect';
}
function outcome(
domains: readonly Readonly<LocalReconciliationApplicationDomainSummary>[],
): LocalReconciliationApplicationPlan['outcome'] {
const adapter = domains.some(
(domain) =>
domain.action === 'adapter_required' ||
domain.action === 'adapter_and_manual',
);
const manual = domains.some(
(domain) =>
domain.action === 'manual_external' ||
domain.action === 'adapter_and_manual',
);
if (adapter && manual) return 'adapter_and_manual_required';
if (adapter) return 'adapter_required';
if (manual) return 'manual_required';
return 'no_effect_ready';
}
export function buildLocalReconciliationApplicationPlan(
intent: Readonly<LocalReconciliationApplicationIntent>,
authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>,
committedAtMs: number,
preparedHeadDigest: string,
): Readonly<LocalReconciliationApplicationPlan> {
if (
authorization.domainDecisionCounts.length !== 8 ||
authorization.decisionCount !==
authorization.domainDecisionCounts.reduce(
(total, selected) =>
total + sumCounts(selected.legacy) + sumCounts(selected.target),
0,
)
) {
configurationError('authorization domain decision summary drifted');
}
const domains = Object.freeze(
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) => {
const selected = authorization.domainDecisionCounts[index];
if (!selected || selected.domain !== domain) {
configurationError('authorization domain ordering drifted');
}
const legacy = databaseSummary(selected.legacy);
const target = databaseSummary(selected.target);
const selectedAction = action(selected);
const summaryPayload = Object.freeze({
domain,
legacy,
target,
action: selectedAction,
});
return Object.freeze({
...summaryPayload,
summaryDigest: cutoverDigest(summaryPayload),
});
}),
);
const payload = Object.freeze({
schema: PLAN_SCHEMA,
schemaVersion: 1 as const,
state: 'reconciliation_application_planned' as const,
applicationId: intent.command.request.applicationId,
reviewId: intent.command.request.reviewId,
profile: intent.profile,
preparationDigest: intent.preparationDigest,
preparedHeadDigest,
reviewDigest: intent.reviewDigest,
authorizationDigest: intent.authorizationDigest,
decisionSetDigest: intent.decisionSetDigest,
decisionCount: intent.decisionCount,
committedAtMs,
domains,
outcome: outcome(domains),
});
const plan = Object.freeze({
...payload,
applicationPlanDigest: cutoverDigest(payload),
});
if (
Buffer.byteLength(`${JSON.stringify(plan, null, 2)}\n`, 'utf8') >
MAX_PLAN_BYTES
) {
configurationError('reconciliation application plan exceeds 64 KiB');
}
return plan;
}
function normalizeDatabaseSummary(
value: unknown,
label: string,
): Readonly<LocalReconciliationApplicationDatabaseDecisionSummary> {
const selected = object(value, label);
exact(selected, ['decisionCount', 'dispositionCounts'], label);
const dispositionCounts = counts(
selected.dispositionCounts,
`${label} disposition counts`,
);
if (
!safeCount(selected.decisionCount) ||
selected.decisionCount !== sumCounts(dispositionCounts)
) {
configurationError(`${label} aggregate drifted`);
}
return Object.freeze({
decisionCount: selected.decisionCount,
dispositionCounts,
}) as Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
}
function normalizeDomain(
value: unknown,
expectedDomain: LocalReconciliationPlanDomain,
): Readonly<LocalReconciliationApplicationDomainSummary> {
const selected = object(value, 'reconciliation application domain');
exact(
selected,
['action', 'domain', 'legacy', 'summaryDigest', 'target'],
'reconciliation application domain',
);
const legacy = normalizeDatabaseSummary(selected.legacy, 'legacy summary');
const target = normalizeDatabaseSummary(selected.target, 'target summary');
const summaryPayload = Object.freeze({
domain: selected.domain,
legacy,
target,
action: selected.action,
});
if (
selected.domain !== expectedDomain ||
![
'no_effect',
'adapter_required',
'manual_external',
'adapter_and_manual',
].includes(selected.action as string) ||
typeof selected.summaryDigest !== 'string' ||
!DIGEST_PATTERN.test(selected.summaryDigest) ||
selected.summaryDigest !== cutoverDigest(summaryPayload)
) {
configurationError('reconciliation application domain drifted');
}
const derivedAction = action({
domain: expectedDomain,
legacy: legacy.dispositionCounts,
target: target.dispositionCounts,
});
if (selected.action !== derivedAction) {
configurationError('reconciliation application domain action drifted');
}
return Object.freeze({
domain: expectedDomain,
legacy,
target,
action: selected.action,
summaryDigest: selected.summaryDigest,
}) as Readonly<LocalReconciliationApplicationDomainSummary>;
}
export function normalizeLocalReconciliationApplicationPlan(
value: unknown,
): Readonly<LocalReconciliationApplicationPlan> {
const plan = object(value, 'reconciliation application plan');
exact(
plan,
[
'applicationId',
'applicationPlanDigest',
'authorizationDigest',
'committedAtMs',
'decisionCount',
'decisionSetDigest',
'domains',
'outcome',
'preparationDigest',
'preparedHeadDigest',
'profile',
'reviewDigest',
'reviewId',
'schema',
'schemaVersion',
'state',
],
'reconciliation application plan',
);
if (!Array.isArray(plan.domains) || plan.domains.length !== 8) {
configurationError('reconciliation application domains are invalid');
}
const domains = Object.freeze(
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) =>
normalizeDomain((plan.domains as unknown[])[index], domain),
),
);
const { applicationPlanDigest, ...rawPayload } = plan;
const payload = Object.freeze({ ...rawPayload, domains });
const domainDecisionCount = domains.reduce(
(total, domain) =>
total + domain.legacy.decisionCount + domain.target.decisionCount,
0,
);
if (
plan.schema !== PLAN_SCHEMA ||
plan.schemaVersion !== 1 ||
plan.state !== 'reconciliation_application_planned' ||
typeof plan.applicationId !== 'string' ||
!UUID_V4_PATTERN.test(plan.applicationId) ||
typeof plan.reviewId !== 'string' ||
!UUID_V4_PATTERN.test(plan.reviewId) ||
(plan.profile !== 'edge' && plan.profile !== 'standalone') ||
!safeCount(plan.decisionCount) ||
plan.decisionCount !== domainDecisionCount ||
!Number.isSafeInteger(plan.committedAtMs) ||
(plan.committedAtMs as number) < 0 ||
![
'no_effect_ready',
'adapter_required',
'manual_required',
'adapter_and_manual_required',
].includes(plan.outcome as string) ||
plan.outcome !== outcome(domains) ||
[
plan.preparationDigest,
plan.preparedHeadDigest,
plan.reviewDigest,
plan.authorizationDigest,
plan.decisionSetDigest,
applicationPlanDigest,
].some(
(candidate) =>
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
) ||
cutoverDigest(payload) !== applicationPlanDigest
) {
configurationError('reconciliation application plan drifted');
}
return Object.freeze({
...(plan as unknown as LocalReconciliationApplicationPlan),
domains,
});
}
export function buildLocalReconciliationApplicationPlanReceipt(
plan: Readonly<LocalReconciliationApplicationPlan>,
): Readonly<LocalReconciliationApplicationPlanReceipt> {
const payload = Object.freeze({
schema: RECEIPT_SCHEMA,
schemaVersion: 1 as const,
state: 'reconciliation_application_planned' as const,
applicationId: plan.applicationId,
reviewId: plan.reviewId,
preparationDigest: plan.preparationDigest,
preparedHeadDigest: plan.preparedHeadDigest,
reviewDigest: plan.reviewDigest,
authorizationDigest: plan.authorizationDigest,
decisionSetDigest: plan.decisionSetDigest,
decisionCount: plan.decisionCount,
applicationPlanDigest: plan.applicationPlanDigest,
outcome: plan.outcome,
domainCount: 8 as const,
committedAtMs: plan.committedAtMs,
});
return Object.freeze({ ...payload, receiptDigest: cutoverDigest(payload) });
}
export function normalizeLocalReconciliationApplicationPlanReceipt(
value: unknown,
): Readonly<LocalReconciliationApplicationPlanReceipt> {
const receipt = object(value, 'reconciliation application receipt');
exact(
receipt,
[
'applicationId',
'applicationPlanDigest',
'authorizationDigest',
'committedAtMs',
'decisionCount',
'decisionSetDigest',
'domainCount',
'outcome',
'preparationDigest',
'preparedHeadDigest',
'receiptDigest',
'reviewDigest',
'reviewId',
'schema',
'schemaVersion',
'state',
],
'reconciliation application receipt',
);
const { receiptDigest, ...payload } = receipt;
if (
receipt.schema !== RECEIPT_SCHEMA ||
receipt.schemaVersion !== 1 ||
receipt.state !== 'reconciliation_application_planned' ||
typeof receipt.applicationId !== 'string' ||
!UUID_V4_PATTERN.test(receipt.applicationId) ||
typeof receipt.reviewId !== 'string' ||
!UUID_V4_PATTERN.test(receipt.reviewId) ||
!safeCount(receipt.decisionCount) ||
receipt.domainCount !== 8 ||
!Number.isSafeInteger(receipt.committedAtMs) ||
(receipt.committedAtMs as number) < 0 ||
![
'no_effect_ready',
'adapter_required',
'manual_required',
'adapter_and_manual_required',
].includes(receipt.outcome as string) ||
[
receipt.preparationDigest,
receipt.preparedHeadDigest,
receipt.reviewDigest,
receipt.authorizationDigest,
receipt.decisionSetDigest,
receipt.applicationPlanDigest,
receiptDigest,
].some(
(candidate) =>
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
) ||
cutoverDigest(payload) !== receiptDigest
) {
configurationError('reconciliation application receipt drifted');
}
return receipt as unknown as Readonly<LocalReconciliationApplicationPlanReceipt>;
}
@@ -9,6 +9,10 @@ import {
} from '@qinglong/runtime-core/security';
import { LocalDeploymentConfigurationError } from '../../foundation/error';
import {
LOCAL_RECONCILIATION_PLAN_DOMAINS,
type LocalReconciliationPlanDomain,
} from '../planning/contract';
import type {
LocalReconciliationReviewDecision,
LocalReconciliationReviewDisposition,
@@ -54,9 +58,20 @@ export interface LocalReconciliationReviewAuthorizationEvidence {
readonly reasonCounts: Readonly<
Record<LocalReconciliationReviewReason, number>
>;
readonly domainDecisionCounts: readonly Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>[];
readonly header: Readonly<LocalReconciliationReviewAuthorizationHeader>;
}
export interface LocalReconciliationReviewAuthorizationDomainDecisionCounts {
readonly domain: LocalReconciliationPlanDomain;
readonly legacy: Readonly<
Record<LocalReconciliationReviewDisposition, number>
>;
readonly target: Readonly<
Record<LocalReconciliationReviewDisposition, number>
>;
}
interface SignatureRecord {
readonly schemaVersion: 1;
readonly kind: typeof SIGNATURE_KIND;
@@ -97,6 +112,32 @@ function zeroCounts<T extends string>(keys: readonly T[]): Record<T, number> {
return Object.fromEntries(keys.map((key) => [key, 0])) as Record<T, number>;
}
function zeroDomainDecisionCounts(): Array<{
domain: LocalReconciliationPlanDomain;
legacy: Record<LocalReconciliationReviewDisposition, number>;
target: Record<LocalReconciliationReviewDisposition, number>;
}> {
return LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain) => ({
domain,
legacy: zeroCounts(DISPOSITIONS),
target: zeroCounts(DISPOSITIONS),
}));
}
function freezeDomainDecisionCounts(
counts: ReturnType<typeof zeroDomainDecisionCounts>,
): readonly Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>[] {
return Object.freeze(
counts.map((selected) =>
Object.freeze({
domain: selected.domain,
legacy: Object.freeze({ ...selected.legacy }),
target: Object.freeze({ ...selected.target }),
}),
),
);
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(
`reconciliation review authorization ${message}`,
@@ -429,6 +470,7 @@ export async function publishLocalReconciliationReviewAuthorization(
let decisionCount = 0;
const dispositionCounts = zeroCounts(DISPOSITIONS);
const reasonCounts = zeroCounts(REASONS);
const domainDecisionCounts = zeroDomainDecisionCounts();
const writeContent = (value: unknown, isDecision: boolean): void => {
const line = canonicalLine(value);
try {
@@ -452,6 +494,11 @@ export async function publishLocalReconciliationReviewAuthorization(
decisionCount += 1;
dispositionCounts[selected.disposition] += 1;
reasonCounts[selected.reason] += 1;
const domain = domainDecisionCounts.find(
(candidate) => candidate.domain === selected.domain,
);
if (!domain) configurationError('decision domain is unavailable');
domain[selected.database][selected.disposition] += 1;
});
if (!DIGEST_PATTERN.test(decisionFile.decisionFileDigest)) {
configurationError('decision file evidence is invalid');
@@ -532,6 +579,7 @@ export async function publishLocalReconciliationReviewAuthorization(
keyId: material.keyId,
dispositionCounts: Object.freeze(dispositionCounts),
reasonCounts: Object.freeze(reasonCounts),
domainDecisionCounts: freezeDomainDecisionCounts(domainDecisionCounts),
header: options.header,
});
} catch (error) {
@@ -668,6 +716,7 @@ export async function verifyLocalReconciliationReviewAuthorization(
}
const dispositionCounts = zeroCounts(DISPOSITIONS);
const reasonCounts = zeroCounts(REASONS);
const domainDecisionCounts = zeroDomainDecisionCounts();
let decisionCount = 0;
let pending = nextLine();
if (pending === null) configurationError('signature is absent');
@@ -679,6 +728,11 @@ export async function verifyLocalReconciliationReviewAuthorization(
decisionCount += 1;
dispositionCounts[selected.disposition] += 1;
reasonCounts[selected.reason] += 1;
const domain = domainDecisionCounts.find(
(candidate) => candidate.domain === selected.domain,
);
if (!domain) configurationError('decision domain is unavailable');
domain[selected.database][selected.disposition] += 1;
pending.fill(0);
pending = following;
}
@@ -740,6 +794,7 @@ export async function verifyLocalReconciliationReviewAuthorization(
keyId: signature.keyId,
dispositionCounts: Object.freeze(dispositionCounts),
reasonCounts: Object.freeze(reasonCounts),
domainDecisionCounts: freezeDomainDecisionCounts(domainDecisionCounts),
header: normalizedHeader,
});
} catch (error) {
@@ -504,6 +504,50 @@ async function readTerminal(
return Object.freeze({ authorization, review, receipt });
}
export interface LocalReconciliationReviewTerminal {
readonly intent: Readonly<LocalReconciliationReviewIntent>;
readonly authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>;
readonly review: Readonly<LocalReconciliationReview>;
readonly receipt: Readonly<LocalReconciliationReviewReceipt>;
}
export async function readLocalReconciliationReviewTerminal(
reviewRoot: string,
reviewId: string,
issuerKeyringPath: string,
uid: number,
): Promise<Readonly<LocalReconciliationReviewTerminal>> {
const selected = paths(reviewRoot, reviewId);
validateDirectory(
selected.root,
uid,
[0o500],
'reconciliationReviewDirectory',
);
validateDirectory(
selected.staging,
uid,
[0o500],
'reconciliationReviewStaging',
);
validateCatalog(selected, true);
const intent = readIntent(selected, uid);
if (
intent.command.options.reviewRoot !== reviewRoot ||
intent.command.request.reviewId !== reviewId
) {
configurationError('terminal reconciliation review path binding drifted');
}
const terminal = await readTerminal(
selected,
intent,
issuerKeyringPath,
uid,
[0o400],
);
return Object.freeze({ intent, ...terminal });
}
function result(
operation: LocalReconciliationReviewTerminalResult['operation'],
status: LocalReconciliationReviewTerminalResult['status'],
@@ -286,6 +286,10 @@ export function assertLocalReconciliationReviewDecisionMatchesFact(
}
if (
fact.decisionRequirement === 'informational' ||
(fact.database === 'legacy' && selected.disposition === 'retain_target') ||
(fact.database === 'target' &&
(selected.disposition === 'adopt_legacy' ||
selected.disposition === 'exclude_legacy')) ||
(fact.decisionRequirement === 'blocked' &&
selected.disposition !== 'defer' &&
selected.disposition !== 'manual_external') ||
@@ -753,7 +753,9 @@ function replayResult(
head.state === 'reconciliation_plan_prepared' ||
head.state === 'reconciliation_planned' ||
head.state === 'reconciliation_review_prepared' ||
head.state === 'reconciliation_reviewed');
head.state === 'reconciliation_reviewed' ||
head.state === 'reconciliation_application_prepared' ||
head.state === 'reconciliation_application_planned');
if (
record.actionId !== intent.actionId ||
record.intentDigest !== intent.intentDigest ||