feat(ql3): add downloadable upgrade readiness

This commit is contained in:
whyour
2026-08-30 14:00:26 +08:00
parent 6e5bfccdfc
commit d6571e4b89
16 changed files with 652 additions and 28 deletions
+2 -5
View File
@@ -240,7 +240,7 @@ function validateMilestoneRecord(record, product, sourceRevision, variant) {
: 'cluster_integration_candidate_not_public_release';
const expectedSchema =
product === 'local'
? 'qinglong/alpha-local-milestone@v2'
? 'qinglong/alpha-local-milestone@v3'
: 'qinglong/alpha-cluster-milestone@v1';
if (
!exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) ||
@@ -382,10 +382,7 @@ function auditAlphaStageIndex(options) {
sourceRevision: manifest.sourceRevision,
workflowRunId: manifest.workflow.runId,
workflowRunAttempt: manifest.workflow.runAttempt,
profiles: [
...manifest.deploymentSelections.local.profiles,
'cluster',
],
profiles: [...manifest.deploymentSelections.local.profiles, 'cluster'],
artifactCount: 10,
compatible: true,
});
+12 -5
View File
@@ -12,7 +12,7 @@ const {
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-milestone@v2';
const SCHEMA = 'qinglong/alpha-local-milestone@v3';
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const FILES = Object.freeze({
readme: 'README.md',
@@ -219,6 +219,7 @@ function bundleRecord(options, architecture) {
applicationImageId: report.applicationImageId,
operatorImageId: report.operatorImageId,
verificationSha256: report.verificationSha256,
upgradeReadinessSha256: report.upgradeReadinessSha256,
});
}
@@ -232,6 +233,7 @@ function validateArtifactRecord(record, architecture, manifest) {
'applicationImageId',
'operatorImageId',
'verificationSha256',
'upgradeReadinessSha256',
]) ||
record.artifactName !==
artifactName(manifest.sourceRevision, architecture, manifest.variant) ||
@@ -245,6 +247,7 @@ function validateArtifactRecord(record, architecture, manifest) {
!SHA256_PATTERN.test(record.applicationImageId || '') ||
!SHA256_PATTERN.test(record.operatorImageId || '') ||
!SHA256_PATTERN.test(record.verificationSha256 || '') ||
!SHA256_PATTERN.test(record.upgradeReadinessSha256 || '') ||
record.applicationImageId === record.operatorImageId
) {
fail(`${architecture} milestone artifact record is incompatible`);
@@ -313,7 +316,7 @@ function auditLocalAlphaMilestone(options) {
'artifacts',
'readme',
]) ||
manifest.schemaVersion !== 2 ||
manifest.schemaVersion !== 3 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
@@ -356,6 +359,8 @@ function auditLocalAlphaMilestone(options) {
new Set(records.map((record) => record.archiveSha256)).size !==
ARCHITECTURES.length ||
new Set(records.map((record) => record.verificationSha256)).size !==
ARCHITECTURES.length ||
new Set(records.map((record) => record.upgradeReadinessSha256)).size !==
ARCHITECTURES.length
) {
fail('milestone architecture subjects are not distinct');
@@ -383,7 +388,7 @@ function auditLocalAlphaMilestone(options) {
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-local-milestone-audit@v2',
schema: 'qinglong/alpha-local-milestone-audit@v3',
sourceRevision: manifest.sourceRevision,
version: manifest.version,
variant: manifest.variant,
@@ -420,7 +425,9 @@ function finalizeLocalAlphaMilestone(options) {
if (
new Set(allImageIds).size !== allImageIds.length ||
artifacts.amd64.archiveSha256 === artifacts.arm64.archiveSha256 ||
artifacts.amd64.verificationSha256 === artifacts.arm64.verificationSha256
artifacts.amd64.verificationSha256 === artifacts.arm64.verificationSha256 ||
artifacts.amd64.upgradeReadinessSha256 ===
artifacts.arm64.upgradeReadinessSha256
) {
fail('milestone architecture subjects must be distinct');
}
@@ -433,7 +440,7 @@ function finalizeLocalAlphaMilestone(options) {
path.join(normalized.outputRoot, FILES.readme),
);
const manifest = {
schemaVersion: 2,
schemaVersion: 3,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
+69 -5
View File
@@ -10,18 +10,23 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-trial-kit@v5';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v3';
const SCHEMA = 'qinglong/alpha-local-trial-kit@v6';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v4';
const QUICKSTART_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-quickstart.sh',
);
const UPGRADE_READINESS_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-upgrade-readiness.sh',
);
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const VARIANTS = Object.freeze(['headless', 'console']);
const ARCHIVE_MIN_BYTES = 1024;
const MAX_JSON_BYTES = 4 * 1024 * 1024;
const MAX_README_BYTES = 512 * 1024;
const MAX_QUICKSTART_BYTES = 256 * 1024;
const MAX_UPGRADE_READINESS_BYTES = 256 * 1024;
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
const FILES = Object.freeze({
@@ -29,6 +34,7 @@ const FILES = Object.freeze({
operatorSbom: 'qinglong3-local-operator.cdx.json',
verificationEvidence: 'verification-evidence.json',
quickstart: 'quickstart.sh',
upgradeReadiness: 'upgrade-readiness.sh',
readme: 'README.md',
manifest: 'manifest.json',
checksums: 'SHA256SUMS',
@@ -44,6 +50,7 @@ const VERIFICATION = Object.freeze({
edgeFreshLifecycle: 'passed',
standaloneFreshLifecycle: 'passed',
localApiCancellation: 'passed',
legacyUpgradeReadiness: 'passed',
});
function verificationGates(variant) {
@@ -447,6 +454,32 @@ function renderQuickstart(identity) {
return rendered;
}
function renderUpgradeReadiness(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
UPGRADE_READINESS_TEMPLATE,
MAX_UPGRADE_READINESS_BYTES,
'upgrade readiness template',
),
'utf8',
);
const replacements = Object.freeze({
'@@OPERATOR_IMAGE@@': identity.images.operator.reference,
'@@OPERATOR_ID@@': identity.images.operator.id,
'@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file,
});
let rendered = template;
for (const [token, value] of Object.entries(replacements)) {
rendered = rendered.replaceAll(token, value);
}
if (/@@[A-Z_]+@@/u.test(rendered)) {
fail('upgrade readiness template contains an unresolved token');
}
return rendered;
}
function fileRecord(bundleRoot, name) {
const filePath = path.join(bundleRoot, name);
const stat = fs.lstatSync(filePath);
@@ -622,8 +655,13 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
renderQuickstart(manifestIdentity),
0o700,
);
writeExclusive(
path.join(normalized.outputRoot, FILES.upgradeReadiness),
renderUpgradeReadiness(manifestIdentity),
0o700,
);
const manifest = {
schemaVersion: 6,
schemaVersion: 7,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
@@ -638,6 +676,10 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
operator: fileRecord(normalized.outputRoot, FILES.operatorSbom),
},
quickstart: fileRecord(normalized.outputRoot, FILES.quickstart),
upgradeReadiness: fileRecord(
normalized.outputRoot,
FILES.upgradeReadiness,
),
readme: fileRecord(normalized.outputRoot, FILES.readme),
verification: fileRecord(
normalized.outputRoot,
@@ -654,6 +696,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.readme,
FILES.manifest,
];
@@ -723,10 +766,11 @@ function auditLocalAlphaTrialKit(options) {
'images',
'sboms',
'quickstart',
'upgradeReadiness',
'readme',
'verification',
]) ||
manifest.schemaVersion !== 6 ||
manifest.schemaVersion !== 7 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
@@ -773,6 +817,23 @@ function auditLocalAlphaTrialKit(options) {
if (actualQuickstart !== expectedQuickstart) {
fail('quickstart differs from the canonical deployment journey');
}
validateFileRecord(
manifest.upgradeReadiness,
FILES.upgradeReadiness,
bundleRoot,
);
const expectedUpgradeReadiness = renderUpgradeReadiness(manifest);
const actualUpgradeReadiness = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.upgradeReadiness),
MAX_UPGRADE_READINESS_BYTES,
'upgrade readiness',
),
'utf8',
);
if (actualUpgradeReadiness !== expectedUpgradeReadiness) {
fail('upgrade readiness differs from the canonical inspection journey');
}
validateFileRecord(manifest.readme, FILES.readme, bundleRoot);
validateOfflineSbom(
readBoundedJson(
@@ -809,6 +870,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
expectedArchive,
].sort();
const actualFiles = fs
@@ -829,6 +891,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.readme,
FILES.manifest,
];
@@ -842,7 +905,7 @@ function auditLocalAlphaTrialKit(options) {
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-local-trial-kit-audit@v2',
schema: 'qinglong/alpha-local-trial-kit-audit@v3',
sourceRevision: manifest.sourceRevision,
version: manifest.version,
architecture: manifest.architecture,
@@ -851,6 +914,7 @@ function auditLocalAlphaTrialKit(options) {
applicationImageId: manifest.images.application.id,
operatorImageId: manifest.images.operator.id,
quickstartSha256: manifest.quickstart.sha256,
upgradeReadinessSha256: manifest.upgradeReadiness.sha256,
verificationSha256: manifest.verification.sha256,
workflowRunId: verificationEvidence.workflow.runId,
workflowRunAttempt: verificationEvidence.workflow.runAttempt,
@@ -0,0 +1,160 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
function fail(message) {
throw new Error(
`QingLong Local Alpha upgrade readiness fixture failed: ${message}`,
);
}
function parseArguments(argv) {
if (argv.length !== 1) fail('usage: --output=/absolute/new/data-root');
const match = /^--output=(\/.+)$/u.exec(argv[0]);
if (!match) fail('output must be an absolute path');
const output = path.resolve(match[1]);
if (output !== match[1] || path.parse(output).root === output) {
fail('output must be a normalized absolute non-root path');
}
return output;
}
function writePrivate(filePath, contents) {
fs.writeFileSync(filePath, contents, {
encoding: 'utf8',
mode: 0o600,
flag: 'wx',
});
}
function createLegacyDatabase(databasePath) {
const database = new DatabaseSync(databasePath);
try {
database.exec(`
CREATE TABLE "Crontabs" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), command VARCHAR(255),
schedule VARCHAR(255), timestamp VARCHAR(255), saved TINYINT(1), status DECIMAL,
isSystem DECIMAL, pid DECIMAL, isDisabled DECIMAL, isPinned DECIMAL,
log_path VARCHAR(255), labels JSON, last_running_time DECIMAL,
last_execution_time DECIMAL, sub_id DECIMAL, extra_schedules JSON,
task_before VARCHAR(255), task_after VARCHAR(255), log_name VARCHAR(255),
allow_multiple_instances DECIMAL, work_dir VARCHAR(255),
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Dependences" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), type DECIMAL,
timestamp VARCHAR(255), status DECIMAL, log JSON, remark VARCHAR(255),
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Apps" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), scopes JSON,
client_id VARCHAR(255), client_secret VARCHAR(255), tokens JSON,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Auths" (
id INTEGER PRIMARY KEY AUTOINCREMENT, ip VARCHAR(255), type VARCHAR(255),
info JSON, createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Envs" (
id INTEGER PRIMARY KEY AUTOINCREMENT, value VARCHAR(255), timestamp VARCHAR(255),
status DECIMAL, position DECIMAL, name VARCHAR(255), remarks VARCHAR(255),
isPinned DECIMAL, labels JSON, createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
CREATE TABLE "Subscriptions" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), url VARCHAR(255),
schedule VARCHAR(255), interval_schedule JSON, type VARCHAR(255),
whitelist VARCHAR(255), blacklist VARCHAR(255), status DECIMAL,
dependences VARCHAR(255), extensions VARCHAR(255), sub_before VARCHAR(255),
sub_after VARCHAR(255), branch VARCHAR(255), pull_type VARCHAR(255),
pull_option JSON, pid DECIMAL, is_disabled DECIMAL, log_path VARCHAR(255),
schedule_type VARCHAR(255), alias VARCHAR(255), proxy VARCHAR(255),
autoAddCron DECIMAL, autoDelCron DECIMAL, createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
CREATE TABLE "CrontabViews" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), position DECIMAL,
isDisabled DECIMAL, filters JSON, sorts JSON, filterRelation VARCHAR(255),
type DECIMAL, createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "CrontabStats" (
id INTEGER PRIMARY KEY AUTOINCREMENT, ref_id DECIMAL NOT NULL,
date VARCHAR(255) NOT NULL, run_count DECIMAL, success_count DECIMAL,
fail_count DECIMAL, total_time DECIMAL, max_time DECIMAL,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "RunningInstances" (
id INTEGER PRIMARY KEY AUTOINCREMENT, cron_id DECIMAL NOT NULL,
run_id VARCHAR(36), attempt_id VARCHAR(36), pid DECIMAL,
log_path VARCHAR(255), started_at DECIMAL NOT NULL, finished_at DECIMAL,
status DECIMAL NOT NULL, exit_code DECIMAL,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "PluginOwnedState" (id INTEGER PRIMARY KEY, payload TEXT NOT NULL);
INSERT INTO "Crontabs" (
id, name, command, schedule, status, isDisabled, isPinned, createdAt, updatedAt
) VALUES (
1, 'Alpha upgrade readiness fixture', 'task /ql/scripts/alpha.sh',
'0 0 * * *', 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO "Envs" (
id, name, value, status, position, createdAt, updatedAt
) VALUES (
1, 'ALPHA_READINESS_VALUE', 'synthetic-only', 0, 100,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO "PluginOwnedState" (id, payload)
VALUES (1, '{"synthetic":true}');
`);
} finally {
database.close();
}
fs.chmodSync(databasePath, 0o600);
}
function createFixture(output) {
if (fs.existsSync(output)) fail('output must not already exist');
fs.mkdirSync(output, { mode: 0o700 });
for (const directory of ['config', 'scripts', 'db', 'upload', 'ssh.d']) {
fs.mkdirSync(path.join(output, directory), { mode: 0o700 });
}
createLegacyDatabase(path.join(output, 'db', 'database.sqlite'));
writePrivate(
path.join(output, 'config', 'config.sh'),
"export ALPHA_READINESS_CONFIG='synthetic-only'\n",
);
writePrivate(
path.join(output, 'scripts', 'alpha.sh'),
"#!/bin/sh\nprintf '%s\\n' 'synthetic alpha readiness'\n",
);
writePrivate(
path.join(output, 'upload', 'README.txt'),
'synthetic fixture\n',
);
return Object.freeze({
output,
database: path.join(output, 'db', 'database.sqlite'),
});
}
function runCli(argv) {
const result = createFixture(parseArguments(argv));
process.stdout.write(`${JSON.stringify({ status: 'created', ...result })}\n`);
}
if (require.main === module) {
try {
runCli(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : 'unknown failure'}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({ createFixture, parseArguments });
@@ -0,0 +1,107 @@
#!/bin/sh
set -eu
OPERATOR_IMAGE='@@OPERATOR_IMAGE@@'
OPERATOR_ID='@@OPERATOR_ID@@'
ARCHITECTURE='@@ARCHITECTURE@@'
SOURCE_REVISION='@@SOURCE_REVISION@@'
ARCHIVE='@@ARCHIVE@@'
fail() {
printf '%s\n' "QingLong Local Alpha upgrade readiness failed: $*" >&2
exit 1
}
usage() {
printf '%s\n' \
'usage: sh upgrade-readiness.sh edge|standalone /absolute/legacy-data-root /absolute/new/evidence-root' >&2
exit 2
}
safe_absolute_path() {
case "$1" in
/|*[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/)
return 1
;;
/*) return 0 ;;
*) return 1 ;;
esac
}
[ "$#" -eq 3 ] || usage
profile=$1
legacy_root=$2
evidence_root=$3
case "$profile" in
edge|standalone) ;;
*) usage ;;
esac
safe_absolute_path "$legacy_root" || fail 'legacy data root is not a safe canonical absolute path'
safe_absolute_path "$evidence_root" || fail 'evidence root is not a safe canonical absolute path'
[ "$legacy_root" != "$evidence_root" ] || fail 'legacy and evidence roots must be distinct'
[ -d "$legacy_root" ] || fail 'legacy data root does not exist'
[ -f "$legacy_root/db/database.sqlite" ] || fail 'legacy db/database.sqlite does not exist'
legacy_real=$(CDPATH= cd -- "$legacy_root" && pwd -P)
[ "$legacy_real" = "$legacy_root" ] || fail 'legacy data root is not canonical'
[ ! -e "$evidence_root" ] || fail 'evidence root must not already exist'
evidence_parent=${evidence_root%/*}
[ -n "$evidence_parent" ] || evidence_parent=/
[ -d "$evidence_parent" ] || fail 'evidence root parent does not exist'
evidence_parent_real=$(CDPATH= cd -- "$evidence_parent" && pwd -P)
[ "$evidence_parent_real/${evidence_root##*/}" = "$evidence_root" ] || fail 'evidence root parent is not canonical'
command -v docker >/dev/null 2>&1 || fail 'docker is required'
command -v sha256sum >/dev/null 2>&1 || fail 'sha256sum is required'
command -v grep >/dev/null 2>&1 || fail 'grep is required'
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
(CDPATH= cd -- "$script_dir" && sha256sum --check SHA256SUMS)
docker info >/dev/null 2>&1 || fail 'docker daemon is unavailable'
docker load --input "$script_dir/$ARCHIVE" >/dev/null
operator_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}|{{index .Config.Labels "io.qinglong.lifecycle"}}|{{index .Config.Labels "io.qinglong.network"}}' "$OPERATOR_IMAGE")
[ "$operator_identity" = "$OPERATOR_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION|short-lived|none-by-default" ] || fail 'operator image identity is incompatible'
old_umask=$(umask)
umask 077
mkdir -m 0700 "$evidence_root"
mkdir -m 0700 "$evidence_root/results"
cat >"$evidence_root/sqlite-inspect.json" <<EOF
{"schemaVersion":1,"operation":"local-sqlite.adoption.inspect","options":{"deploymentRoot":"/var/lib/qinglong3","profile":"$profile","sourcePath":"/var/lib/qinglong2/db/database.sqlite"}}
EOF
cat >"$evidence_root/data-directory-inspect.json" <<EOF
{"schemaVersion":1,"operation":"local-data-directory.adoption.inspect","options":{"dataRoot":"/var/lib/qinglong2","profile":"$profile"}}
EOF
chmod 0600 "$evidence_root/sqlite-inspect.json" "$evidence_root/data-directory-inspect.json"
uid=$(id -u)
gid=$(id -g)
run_inspect() {
command_file=$1
result_file=$2
docker run --rm --read-only --user "$uid:$gid" --network none \
--cap-drop ALL --security-opt no-new-privileges \
--memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \
--mount "type=bind,src=$legacy_root,dst=/var/lib/qinglong2,readonly" \
--mount "type=bind,src=$evidence_root,dst=/var/lib/qinglong3" \
"$OPERATOR_IMAGE" adoption run \
--command-file "/var/lib/qinglong3/$command_file" \
>"$evidence_root/results/$result_file"
}
run_inspect sqlite-inspect.json sqlite-inspect.result.json
grep -q '"status":"inspected"' "$evidence_root/results/sqlite-inspect.result.json" || fail 'SQLite inspect did not report inspected'
run_inspect data-directory-inspect.json data-directory-inspect.result.json
grep -q '"status":"inspected"' "$evidence_root/results/data-directory-inspect.result.json" || fail 'data-directory inspect did not report inspected'
umask "$old_umask"
printf '%s\n' \
"QingLong 2.x upgrade readiness inspection completed ($profile, $ARCHITECTURE)." \
"SQLite plan: $evidence_root/results/sqlite-inspect.result.json" \
"Data-directory plan: $evidence_root/results/data-directory-inspect.result.json" \
'The legacy data root was mounted read-only; no stage, activation, cutover or rollback was authorized.' \
'Review both complete results and preserve their exact plan digests before any later rehearsal phase.'