feat(ql3): fence reconciliation capture preparation

This commit is contained in:
whyour
2026-08-21 15:54:49 +08:00
parent 1bf14f807c
commit a789c4a4d6
7 changed files with 995 additions and 1 deletions
@@ -23,6 +23,8 @@ export type LocalCutoverInstanceHeadState =
| 'legacy_stopped'
| 'target_active'
| 'target_stopped'
| 'reconciliation_capture_prepared'
| 'reconciliation_captured'
| 'rollback_prepared'
| 'legacy_restart_requested'
| 'legacy_running'
@@ -154,6 +156,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
head.state !== 'legacy_stopped' &&
head.state !== 'target_active' &&
head.state !== 'target_stopped' &&
head.state !== 'reconciliation_capture_prepared' &&
head.state !== 'reconciliation_captured' &&
head.state !== 'rollback_prepared' &&
head.state !== 'legacy_restart_requested' &&
head.state !== 'legacy_running' &&
@@ -320,6 +324,8 @@ export function advanceLocalCutoverInstanceHead(
| 'legacy_stopped'
| 'target_active'
| 'target_stopped'
| 'reconciliation_capture_prepared'
| 'reconciliation_captured'
| 'rollback_prepared'
| 'legacy_restart_requested'
| 'legacy_running'
@@ -358,6 +364,8 @@ export function advanceLocalCutoverInstanceHead(
state === 'target_stopped' &&
current.generation === generation &&
(current.state === 'rollback_prepared' ||
current.state === 'reconciliation_capture_prepared' ||
current.state === 'reconciliation_captured' ||
current.state === 'legacy_restart_requested' ||
current.state === 'legacy_running' ||
current.state === 'legacy_ready')
@@ -373,6 +381,10 @@ export function advanceLocalCutoverInstanceHead(
(current.state === 'legacy_stopped' ||
current.state === 'target_active')) ||
(state === 'target_stopped' && current.state === 'target_active') ||
(state === 'reconciliation_capture_prepared' &&
current.state === 'target_stopped') ||
(state === 'reconciliation_captured' &&
current.state === 'reconciliation_capture_prepared') ||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
(state === 'legacy_restart_requested' &&
current.state === 'rollback_prepared') ||
@@ -93,6 +93,10 @@ import {
consumeLocalServiceManagerLegacyRollback,
consumeLocalServiceManagerLegacyRollbackCommandFile,
} from './service-manager/legacy-rollback/consumer';
import {
prepareLocalReconciliationCapture,
prepareLocalReconciliationCaptureCommandFile,
} from './reconciliation/preparation';
export {
LocalDeploymentConfigurationError,
@@ -122,6 +126,16 @@ export {
type LocalDeploymentStatusCommand,
type LocalDeploymentStatusResult,
} from './foundation/contract';
export {
normalizeLocalReconciliationCapturePrepareCommand,
type LocalReconciliationCapturePrepareCommand,
type LocalReconciliationCapturePrepareResult,
type LocalReconciliationStoppedAuthority,
} from './reconciliation/contract';
export {
localReconciliationCaptureDirectory,
localReconciliationCaptureIntentPath,
} from './reconciliation/preparation';
export {
prepareLocalDeploymentAdoptedBundle,
runLocalDeploymentAdoptedBundleCommandFile,
@@ -225,6 +239,8 @@ export {
authorizeLocalServiceManagerLegacyRollbackCommandFile,
consumeLocalServiceManagerLegacyRollback,
consumeLocalServiceManagerLegacyRollbackCommandFile,
prepareLocalReconciliationCapture,
prepareLocalReconciliationCaptureCommandFile,
};
export {
localServiceManagerIntentDigest,
@@ -13,6 +13,7 @@ import {
preflightLocalDeploymentComposeCommandFile,
prepareLocalServiceManagerIntentCommandFile,
prepareLocalServiceManagerLegacyRollbackCommandFile,
prepareLocalReconciliationCaptureCommandFile,
prepareLocalDeploymentCommandFile,
proveLocalDeploymentLegacyReadinessCommandFile,
restoreLocalDeploymentComposeCommitCommandFile,
@@ -27,7 +28,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|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|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')) {
@@ -56,6 +57,7 @@ async function main(argv: readonly string[]): Promise<void> {
argv[0] !== 'cutover-manual-diagnose' &&
argv[0] !== 'cutover-manual-resolution-prepare' &&
argv[0] !== 'cutover-manual-resolution-commit' &&
argv[0] !== 'reconciliation-capture-prepare' &&
argv[0] !== 'compose-revision' &&
argv[0] !== 'compose-preflight' &&
argv[0] !== 'compose-apply' &&
@@ -126,6 +128,8 @@ async function main(argv: readonly string[]): Promise<void> {
? 'local.deployment.cutover.manual-resolution-prepare'
: 'local.deployment.cutover.manual-resolution-commit',
)
: argv[0] === 'reconciliation-capture-prepare'
? prepareLocalReconciliationCaptureCommandFile(argv[2]!)
: argv[0] === 'compose-revision'
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
: argv[0] === 'compose-preflight'
@@ -0,0 +1,220 @@
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 type LocalReconciliationStoppedAuthority =
| 'docker'
| 'service-manager';
export interface LocalReconciliationCapturePrepareCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.capture.prepare';
readonly options: Readonly<{
deploymentRoot: string;
captureRoot: string;
allowRootService: boolean;
}>;
readonly request: Readonly<{
captureId: string;
stoppedAuthority: LocalReconciliationStoppedAuthority;
profile: 'edge' | 'standalone';
instanceId: string;
cutoverId: string;
generation: number;
activationPath: string;
legacySourcePath: string;
targetDatabasePath: string;
recoveryPath: string;
expectedActivationDigest: string;
expectedHeadDigest: string;
expectedStoppedRecordDigest: string;
preparedAtMs: number;
}>;
}
export interface LocalReconciliationCapturePrepareResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.reconciliation.capture.prepare';
readonly status: 'prepared' | 'existing';
readonly state: 'reconciliation_capture_prepared';
readonly captureId: string;
readonly preparationDigest: string;
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 safeAbsolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value ||
value.includes('\0') ||
value.includes('//') ||
!SAFE_PATH_PATTERN.test(value) ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
configurationError(`${label} must be a safe non-root absolute path`);
}
return value;
}
function digest(value: unknown, label: string): string {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
configurationError(`${label} must be a SHA-256 digest`);
}
return value;
}
export function normalizeLocalReconciliationCapturePrepareCommand(
value: unknown,
): Readonly<LocalReconciliationCapturePrepareCommand> {
const command = object(value, 'reconciliation capture prepare command');
exact(
command,
['operation', 'options', 'request', 'schemaVersion'],
'command',
);
const options = object(command.options, 'options');
exact(
options,
['allowRootService', 'captureRoot', 'deploymentRoot'],
'options',
);
const request = object(command.request, 'request');
exact(
request,
[
'activationPath',
'captureId',
'cutoverId',
'expectedActivationDigest',
'expectedHeadDigest',
'expectedStoppedRecordDigest',
'generation',
'instanceId',
'legacySourcePath',
'preparedAtMs',
'profile',
'recoveryPath',
'stoppedAuthority',
'targetDatabasePath',
],
'request',
);
const identity = currentIdentity();
if (
command.schemaVersion !== 1 ||
command.operation !==
'local.deployment.reconciliation.capture.prepare' ||
typeof options.allowRootService !== 'boolean' ||
(identity.uid === 0) !== options.allowRootService ||
typeof request.captureId !== 'string' ||
!UUID_V4_PATTERN.test(request.captureId) ||
(request.stoppedAuthority !== 'docker' &&
request.stoppedAuthority !== 'service-manager') ||
(request.profile !== 'edge' && request.profile !== 'standalone') ||
typeof request.instanceId !== 'string' ||
request.instanceId.length < 1 ||
request.instanceId.length > 128 ||
typeof request.cutoverId !== 'string' ||
request.cutoverId.length < 1 ||
request.cutoverId.length > 128 ||
!Number.isSafeInteger(request.generation) ||
(request.generation as number) < 1 ||
!Number.isSafeInteger(request.preparedAtMs) ||
(request.preparedAtMs as number) < 0
) {
configurationError('reconciliation capture prepare command is invalid');
}
const deploymentRoot = safeAbsolutePath(
options.deploymentRoot,
'deploymentRoot',
);
const captureRoot = safeAbsolutePath(options.captureRoot, 'captureRoot');
if (captureRoot === deploymentRoot) {
configurationError('captureRoot must be distinct from deploymentRoot');
}
return Object.freeze({
schemaVersion: 1 as const,
operation:
'local.deployment.reconciliation.capture.prepare' as const,
options: Object.freeze({
deploymentRoot,
captureRoot,
allowRootService: options.allowRootService,
}),
request: Object.freeze({
captureId: request.captureId,
stoppedAuthority: request.stoppedAuthority,
profile: request.profile,
instanceId: request.instanceId,
cutoverId: request.cutoverId,
generation: request.generation as number,
activationPath: safeAbsolutePath(
request.activationPath,
'activationPath',
),
legacySourcePath: safeAbsolutePath(
request.legacySourcePath,
'legacySourcePath',
),
targetDatabasePath: safeAbsolutePath(
request.targetDatabasePath,
'targetDatabasePath',
),
recoveryPath: safeAbsolutePath(request.recoveryPath, 'recoveryPath'),
expectedActivationDigest: digest(
request.expectedActivationDigest,
'expectedActivationDigest',
),
expectedHeadDigest: digest(
request.expectedHeadDigest,
'expectedHeadDigest',
),
expectedStoppedRecordDigest: digest(
request.expectedStoppedRecordDigest,
'expectedStoppedRecordDigest',
),
preparedAtMs: request.preparedAtMs as number,
}),
});
}
@@ -0,0 +1,181 @@
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { currentIdentity } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import {
ensurePrivateDirectory,
preflightPublishedFile,
publishExactFile,
validatePrivateDirectory,
} from '../foundation/files';
import {
advanceLocalCutoverInstanceHead,
readLocalCutoverInstanceHead,
} from '../cutover/instanceLineage';
import { cutoverDigest } from '../cutover/targetEvidence';
import {
normalizeLocalReconciliationCapturePrepareCommand,
type LocalReconciliationCapturePrepareCommand,
type LocalReconciliationCapturePrepareResult,
} from './contract';
import { proveLocalReconciliationStoppedState } from './stoppedProof';
const INTENT_SCHEMA = 'qinglong3-local-reconciliation-capture-intent';
interface LocalReconciliationCaptureIntent {
readonly schema: typeof INTENT_SCHEMA;
readonly schemaVersion: 1;
readonly state: 'reconciliation_capture_prepared';
readonly command: Readonly<LocalReconciliationCapturePrepareCommand>;
readonly stoppedProofDigest: string;
readonly reconciliationEvidenceDigest: string;
readonly preparationDigest: string;
}
function configurationError(message: string): never {
throw new LocalDeploymentConfigurationError(message);
}
export function localReconciliationCaptureDirectory(
captureRoot: string,
captureId: string,
): string {
return path.join(captureRoot, captureId);
}
export function localReconciliationCaptureIntentPath(
captureRoot: string,
captureId: string,
): string {
return path.join(
localReconciliationCaptureDirectory(captureRoot, captureId),
'intent.json',
);
}
function intentContents(intent: Readonly<LocalReconciliationCaptureIntent>): string {
return `${JSON.stringify(intent, null, 2)}\n`;
}
export function prepareLocalReconciliationCapture(
input: unknown,
): Readonly<LocalReconciliationCapturePrepareResult> {
const command = normalizeLocalReconciliationCapturePrepareCommand(input);
const identity = currentIdentity();
validatePrivateDirectory(
command.options.deploymentRoot,
identity.uid,
'deploymentRoot',
);
validatePrivateDirectory(
command.options.captureRoot,
identity.uid,
'captureRoot',
);
const head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
command.request.instanceId,
identity.uid,
);
if (
head.profile !== command.request.profile ||
head.cutoverId !== command.request.cutoverId ||
head.activationDigest !== command.request.expectedActivationDigest ||
head.generation !== command.request.generation ||
(head.state === 'target_stopped' &&
(head.headDigest !== command.request.expectedHeadDigest ||
head.sourceRecordDigest !==
command.request.expectedStoppedRecordDigest)) ||
(head.state !== 'target_stopped' &&
head.state !== 'reconciliation_capture_prepared')
) {
configurationError(
'capture prepare lost the stopped instance head compare-and-swap',
);
}
const proof = proveLocalReconciliationStoppedState(command, identity.uid);
const payload = Object.freeze({
schema: INTENT_SCHEMA,
schemaVersion: 1 as const,
state: 'reconciliation_capture_prepared' as const,
command,
stoppedProofDigest: proof.proofDigest,
reconciliationEvidenceDigest: proof.reconciliationEvidenceDigest,
});
const intent: Readonly<LocalReconciliationCaptureIntent> = Object.freeze({
...payload,
preparationDigest: cutoverDigest(payload),
});
if (
head.state === 'reconciliation_capture_prepared' &&
head.sourceRecordDigest !== intent.preparationDigest
) {
configurationError('another capture owns the reconciliation fence');
}
const captureDirectory = localReconciliationCaptureDirectory(
command.options.captureRoot,
command.request.captureId,
);
ensurePrivateDirectory(captureDirectory, identity.uid, 'captureDirectory');
ensurePrivateDirectory(
path.join(captureDirectory, 'staging'),
identity.uid,
'captureStagingDirectory',
);
const intentPath = localReconciliationCaptureIntentPath(
command.options.captureRoot,
command.request.captureId,
);
const contents = intentContents(intent);
preflightPublishedFile(
intentPath,
contents,
0o600,
identity.uid,
'reconciliation capture intent',
);
const nextHead =
head.state === 'reconciliation_capture_prepared'
? head
: advanceLocalCutoverInstanceHead(
{
options: { deploymentRoot: command.options.deploymentRoot },
request: {
cutoverId: command.request.cutoverId,
profile: command.request.profile,
instanceId: command.request.instanceId,
expectedActivationDigest:
command.request.expectedActivationDigest,
requestedAtMs: command.request.preparedAtMs,
},
},
identity.uid,
'reconciliation_capture_prepared',
command.request.generation,
intent.preparationDigest,
);
const status = publishExactFile(
intentPath,
contents,
0o600,
identity.uid,
'reconciliation capture intent',
);
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status,
state: 'reconciliation_capture_prepared' as const,
captureId: command.request.captureId,
preparationDigest: intent.preparationDigest,
instanceHeadDigest: nextHead.headDigest,
});
}
export function prepareLocalReconciliationCaptureCommandFile(
filePath: string,
): Readonly<LocalReconciliationCapturePrepareResult> {
return prepareLocalReconciliationCapture(readPrivateLocalCommandFile(filePath));
}
@@ -0,0 +1,205 @@
import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import { cutoverDigest } from '../cutover/targetEvidence';
import {
readTargetDataReconciliationEvidenceForPaths,
verifyTargetDataReconciliationEvidence,
type TargetDataReconciliationEvidence,
} from '../cutover/targetDataEvidence';
import {
targetStopPhasePath,
targetStopSequence,
} from '../cutover/target-run/targetRunJournal';
import { normalizeLocalServiceManagerCutoverRecord } from '../service-manager/serviceCutoverJournal';
import type { LocalReconciliationCapturePrepareCommand } from './contract';
const DOCKER_RECORD_SCHEMA = 'qinglong3-local-cutover-journal-record';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
export interface LocalReconciliationStoppedProof {
readonly stoppedRecordDigest: string;
readonly reconciliationEvidenceDigest: string;
readonly proofDigest: 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 serviceManagerStoppedPath(
command: Readonly<LocalReconciliationCapturePrepareCommand>,
): string {
return path.join(
command.options.deploymentRoot,
'service',
'cutovers',
command.request.cutoverId,
`service-manager-g${String(command.request.generation).padStart(
2,
'0',
)}-stopped.json`,
);
}
function dockerStoppedEvidence(
command: Readonly<LocalReconciliationCapturePrepareCommand>,
): Readonly<TargetDataReconciliationEvidence> {
const recordPath = targetStopPhasePath(
path.join(
command.options.deploymentRoot,
'service',
'cutovers',
command.request.cutoverId,
),
command.request.generation,
'outcome',
);
const record = object(
readPrivateLocalCommandFile(recordPath),
'docker target stopped record',
);
exact(
record,
[
'activationDigest',
'cutoverId',
'evidence',
'generation',
'instanceId',
'previousRecordDigest',
'profile',
'recordDigest',
'requestedAtMs',
'schema',
'schemaVersion',
'sequence',
'state',
],
'docker target stopped record',
);
const { recordDigest, ...payload } = record;
const evidence = object(record.evidence, 'docker target stopped evidence');
exact(
evidence,
[
'activeRecordDigest',
'reconciliation',
'startupReceiptDigest',
'targetApplicationBindingDigest',
'targetContainerIdentityDigest',
],
'docker target stopped evidence',
);
if (
record.schema !== DOCKER_RECORD_SCHEMA ||
record.schemaVersion !== 1 ||
record.sequence !==
targetStopSequence(command.request.generation, 'outcome') ||
record.state !== 'target_stopped' ||
record.cutoverId !== command.request.cutoverId ||
record.profile !== command.request.profile ||
record.instanceId !== command.request.instanceId ||
record.activationDigest !== command.request.expectedActivationDigest ||
record.generation !== command.request.generation ||
recordDigest !== command.request.expectedStoppedRecordDigest ||
typeof record.previousRecordDigest !== 'string' ||
!DIGEST_PATTERN.test(record.previousRecordDigest) ||
!Number.isSafeInteger(record.requestedAtMs) ||
(record.requestedAtMs as number) < 0 ||
typeof recordDigest !== 'string' ||
!DIGEST_PATTERN.test(recordDigest) ||
cutoverDigest(payload) !== recordDigest
) {
configurationError('docker target stopped record drifted');
}
return verifyTargetDataReconciliationEvidence(evidence.reconciliation);
}
function serviceManagerStoppedRecord(
command: Readonly<LocalReconciliationCapturePrepareCommand>,
): void {
const record = normalizeLocalServiceManagerCutoverRecord(
readPrivateLocalCommandFile(serviceManagerStoppedPath(command)),
);
if (
record.state !== 'target_stopped' ||
record.cutoverId !== command.request.cutoverId ||
record.profile !== command.request.profile ||
record.instanceId !== command.request.instanceId ||
record.activationDigest !== command.request.expectedActivationDigest ||
record.generation !== command.request.generation ||
record.recordDigest !== command.request.expectedStoppedRecordDigest ||
record.action !== 'stop' ||
record.evidence.shutdownReceiptDigest === null ||
record.evidence.manualReason !== null
) {
configurationError('service manager target stopped record drifted');
}
}
export function proveLocalReconciliationStoppedState(
command: Readonly<LocalReconciliationCapturePrepareCommand>,
uid: number,
): Readonly<LocalReconciliationStoppedProof> {
const persisted =
command.request.stoppedAuthority === 'docker'
? dockerStoppedEvidence(command)
: (serviceManagerStoppedRecord(command), undefined);
const current = readTargetDataReconciliationEvidenceForPaths(
{
profile: command.request.profile,
activationPath: command.request.activationPath,
legacySourcePath: command.request.legacySourcePath,
targetDatabasePath: command.request.targetDatabasePath,
expectedActivationDigest: command.request.expectedActivationDigest,
},
uid,
);
if (
current.disposition !== 'reconciliation_required' ||
(persisted !== undefined &&
persisted.evidenceDigest !== current.evidenceDigest)
) {
configurationError(
'stopped data does not have exact reconciliation-required evidence',
);
}
const payload = Object.freeze({
stoppedAuthority: command.request.stoppedAuthority,
stoppedRecordDigest: command.request.expectedStoppedRecordDigest,
reconciliationEvidenceDigest: current.evidenceDigest,
});
return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) });
}