From c218f71250609466eeb43ba9728258ecd00d367e Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 21 Aug 2026 20:24:41 +0800 Subject: [PATCH] feat(ql3): plan reconciliation application --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 19 + ...-reconciliation-application-coordinator.md | 191 ++++ .../src/deployment/cutover/instanceLineage.ts | 12 + .../src/deployment/localDeployment.ts | 42 + .../src/deployment/localDeploymentCli.ts | 14 +- .../reconciliation/application/contract.ts | 372 ++++++ .../reconciliation/application/coordinator.ts | 1010 +++++++++++++++++ .../reconciliation/application/plan.ts | 510 +++++++++ .../reconciliation/review/authorization.ts | 55 + .../reconciliation/review/completion.ts | 44 + .../reconciliation/review/decisionFile.ts | 4 + .../service-manager/serviceCutoverConsumer.ts | 4 +- .../reconciliationCapturePrepare.test.cjs | 358 +++++- test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 14 files changed, 2634 insertions(+), 5 deletions(-) create mode 100644 docs/adr/ADR-0485-reconciliation-application-coordinator.md create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/application/contract.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/application/coordinator.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/application/plan.ts diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 6a4f1ffe..c708165b 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,25 @@ 最新增量证据(2026-08-21): +- 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`,以 + `reconciliation_reviewed → reconciliation_application_prepared → reconciliation_application_planned` CAS 建立唯一领域执行根。coordinator + 重新验证 sealed review、issuer HMAC、decision-set 与 instance head,只用固定 counters 生成八领域、Legacy/Target 双侧、content-free 的 + disposition/action plan;plan 不包含名称、路径、fact digest、row value、command、Secret、reviewer identity,且上限 64 KiB。Legacy fact + 现拒绝 `retain_target`,Target fact 拒绝 `adopt_legacy/exclude_legacy`,修复了 D-391 的数据库方向语义缺口。intent/plan/receipt/seal/head + 全部 no-replace exact replay,terminal 收敛为 `0400/0500`,专用只读 reader 不放宽通用 `0600` command-file policy。prepare/commit/verify + 均不打开 SQLite、不执行 SQL/DML、Secret 解密、service/Docker/network,也不授予 restart/completion。实现位于既有 + `deployment/reconciliation/application/` 的 contract/plan/coordinator 三个职责文件,没有新增 package、dependency、binary、daemon 或 + `src/` 根平铺。聚焦套件 `35 total / 33 pass / 2 conditional Docker skip / 0 fail`,完整 Local Owner + `257 total / 250 pass / 7 conditional skip / 0 fail`,Git 跟踪 backend + `1541 total / 1539 pass / 2 conditional skip / 0 fail`;包含用户未提交测试的工作区 backend 也以 `1542/1540/2/0` 通过。18-package clean + build/逐包测试、八项架构/部署审计、真实 Docker readonly reconciliation `2/2` 与十四档 artifact audit 全通过。workspace 仍为 18 + packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `158 source / 157 nested / 1 root binary entry`。基础 + Edge/Standalone 精确保持 `2,611,978 / 2,612,056 bytes`、319 files、58 modules。虽无 SQL/Cluster 变更,仍额外重跑 PostgreSQL 18.6 + arm64 physical HA:146 gates、timeline `1 → 2`,private evidence SHA-256 + `632d71f2a5b33cf657476fbe41702064b609b12cdf2382bcddaef06f4d08279f`,独立审计无 finding,Docker 资源已清理。D-393 才能以独立 ADR + 定义首个 Automation 行级 adapter、写前 backup、重新认证、幂等冲突、commit/replay 与 rollback。 - D-391/ADR-0484(已接受):既有 Local Owner 已完整实现 `reconciliation.review.prepare|diagnostics|commit|verify`,以 `reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把一个 diff --git a/docs/adr/ADR-0485-reconciliation-application-coordinator.md b/docs/adr/ADR-0485-reconciliation-application-coordinator.md new file mode 100644 index 00000000..1f4d0126 --- /dev/null +++ b/docs/adr/ADR-0485-reconciliation-application-coordinator.md @@ -0,0 +1,191 @@ +# ADR-0485:Reconciliation 应用协调器与领域 Adapter 边界 + +- 状态:Accepted(D-392 已实现并完成门禁) +- 日期:2026-08-21 +- 关联 RFC:QL-RFC-0001 D-05、D-06、D-17、D-64、D-87、D-184、D-259、D-383、D-389、D-390、D-391、D-392 +- 关联 ADR:ADR-0482、ADR-0483、ADR-0484 +- 细化:ADR-0484 的签名人工裁决消费边界 + +## 背景 + +ADR-0484 已把密封 reconciliation plan 转换为逐事实、强认证 User 签名的 review authorization,但该 authorization 仍只表达 +operator 对 schema object/table 事实的选择。它没有定义任何领域的行级兼容、幂等键、写入顺序、备份、回滚或外部资产语义。 + +直接让通用导入器消费 review 会产生错误的授权提升。例如 Automation 表被选择为 `adopt_legacy`,并不意味着表内每个 Legacy shell +command 都能转换为 3.0 TaskDefinition,也不能证明目标 task id、timezone、disabled state 和 trigger 没有冲突。Secret、Run history、 +Plugin package、AI/Tool 与 Identity/Policy/Audit 的安全语义差异更大,不存在一套可信的通用 DML。 + +D-392 因此只建立一个持久、内容无关的应用顺序根。它把签名 decision stream 汇总成固定八领域计划,为后续 adapter 提供唯一 fence, +但不执行迁移。 + +## 决策 + +### 1. 增加独立 application coordinator 状态机 + +既有 `ql3-local-deploy` 增加三个显式私有命令: + +```text +local.deployment.reconciliation.application.prepare +local.deployment.reconciliation.application.commit +local.deployment.reconciliation.application.verify +``` + +状态以 instance head CAS 推进: + +```text +reconciliation_reviewed + → reconciliation_application_prepared + → reconciliation_application_planned +``` + +`application.prepare` 必须绑定 exact `reviewId`、`reviewDigest` 和 reviewed head digest。它建立唯一 application fence 后,以 no-replace +方式发布 intent。第二个 application、旧 review replay、rollback、restart 或越级 commit 都必须失败关闭。 + +`reconciliation_application_planned` 只表示领域执行顺序已固定,不表示 reconciliation 完成、数据库已迁移或 target 可以重启。 + +### 2. Application plan 固定为八领域内容无关摘要 + +coordinator 重新验证 D-391 的 sealed review directory、authorization HMAC、issuer keyring、review/receipt binding 和 decision-set digest, +并在读取签名 NDJSON 时以固定 counter 统计: + +```text +schema_lineage +automation +secret_and_config +run_history +plugin_package +ai_and_tool +identity_policy_audit +unknown +``` + +每个领域只保存 Legacy/Target 两侧的 decision count、固定 disposition counts、summary digest 与 action: + +```text +no_effect | adapter_required | manual_external | adapter_and_manual +``` + +总体 outcome 为: + +```text +no_effect_ready | adapter_required | +manual_required | adapter_and_manual_required +``` + +plan 不保存对象名、表名、路径、fact digest、row value、command、schedule、Secret、credential、Prompt/Tool payload、Artifact、日志或 reviewer +identity。它最多 64 KiB,domain 数固定为 8,内存只使用固定 counters 与 64 KiB 读取缓冲。 + +### 3. 修正 disposition 的数据库方向语义 + +D-391 已限制 blocked domain,但原始校验没有拒绝数据库方向矛盾的选择。D-392 明确: + +- Legacy fact 不允许 `retain_target`; +- Target fact 不允许 `adopt_legacy` 或 `exclude_legacy`; +- `retain_both`、`defer` 与 `manual_external` 仍受既有 domain/blocked 规则约束; +- informational fact 仍不得出现在 decision stream。 + +该规则在签名发布前对重新派生的 canonical fact 生效,不依赖 diagnostics page。 + +### 4. 持久化、封存与崩溃恢复 + +`applicationRoot` 必须与 deployment、capture、plan、review root 两两不重叠;issuer keyring 必须位于 deployment root 的真后代。catalog 最多 +保留 64 个 application directory,每个目录只含: + +```text +intent.json +plan.json +receipt.json +staging/ +``` + +intent、plan、receipt 使用 no-replace publication、stable file identity、file/directory fsync 和 digest binding。prepare head、plan、receipt、 +terminal seal 与 planned head 后的 response loss 均必须 exact replay;冲突内容不允许覆盖或自动清理。terminal 文件收敛为 `0400`,目录与 +空 staging 收敛为 `0500`。专用 terminal reader 在读取前后复核 owner、mode、link count、device/inode、size、mtime/ctime,不放宽通用 +`0600` command-file policy。 + +### 5. Verify 完全只读 + +verify 只读取 sealed application evidence、sealed review authorization、issuer verification key 与 instance head。它不创建、替换、修复或 +清理文件,不打开 Legacy/Target SQLite,不执行 checkpoint、SQL/DML、Secret 解密、Docker/init/service/network 或后台 retry。 + +应用 coordinator 的 prepare/commit 同样不得打开 SQLite。它消费的是 D-391 已签名、内容完整性受保护的 decision stream,而不是重新读取 +数据库。每个未来 adapter 在写入前仍必须重新验证 exact application plan/head 和自己的领域输入。 + +### 6. 领域 Adapter 必须独立授权和回滚 + +D-392 不提供通用 apply。后续每个 adapter 至少要单独定义: + +- 可接受的 source/target row schema 与兼容矩阵; +- Project/Policy/Owner authority 与需要重新认证的操作; +- 幂等 identity、冲突规则、写入顺序和 response-loss replay; +- 写前 backup、失败保全、rollback 与不可逆边界; +- Secret custody、append-only history、外部文件/OCI/KMS 等领域特有语义; +- Edge 的 RSS/I/O/磁盘峰值与 Cluster 的并发、租约和 HA 语义。 + +第一候选为 Automation adapter,但它必须先生成逐行、可审查、可回滚的 TaskDefinition/Trigger 转换计划,不能把 D-391 的表级选择直接解释为 +批量 INSERT/UPDATE。 + +### 7. Package 与部署边界 + +实现内聚在既有 `@qinglong/local-owner-cli/src/deployment/reconciliation/application/`,按 contract、plan、coordinator 三个职责文件组织。 +不新增 workspace package、production dependency、binary、daemon、timer、watcher、listener 或 socket,也不把文件平铺到 package `src/` +根。基础 Edge/Standalone runtime artifact 不应携带一次性 application authority;Cluster/PostgreSQL/Kubernetes 不读取 Local application root。 + +## 当前实现进度 + +D-392 已实现三条命令、两阶段 instance CAS、八领域 content-free plan、signed authorization domain counters、数据库方向校验、`0400/0500` +terminal seal、完整 response-loss replay 和只读 verify。聚焦 reconciliation 套件为 +`35 total / 33 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner 为 +`257 total / 250 pass / 7 conditional skip / 0 fail`;Git 跟踪 backend 为 +`1541 total / 1539 pass / 2 conditional skip / 0 fail`。包含用户未提交测试的当前工作区 backend 也以 +`1542 total / 1540 pass / 2 conditional skip / 0 fail` 通过。18-package clean build/逐包测试、八项架构/部署审计与真实 Docker readonly +reconciliation `2/2` 全部通过。 + +十四档 artifact audit 全部 `compatible`;基础 Edge/Standalone 精确保持 +`2,611,978 / 2,612,056 bytes`、319 files、58 loaded modules,证明一次性 application authority 未进入低配常驻闭包。workspace 仍为 18 +packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 +`158 source / 157 nested / 1 root binary entry`。新增 3 个生产源码全部位于既有 `deployment/reconciliation/application/`,没有新增 package、 +dependency、binary 或常驻对象。 + +本切片没有 SQL、SQLite open、数据库 schema、PostgreSQL driver/ACL/role/Pool、Cluster deployment 或 HA 拓扑改动;仍按 operator 授权额外 +重跑 PostgreSQL 18.6 arm64 physical HA,以 146 gates、timeline `1 → 2` 通过。private evidence SHA-256 为 +`632d71f2a5b33cf657476fbe41702064b609b12cdf2382bcddaef06f4d08279f`,独立 evidence audit 无 finding,临时 Docker container/network 已 +清理。该证明是额外回归证据,不把 Local application coordinator 解释为 Cluster reconciliation authority。 + +## 被拒绝的替代方案 + +### 让 D-391 review 直接调用 Automation adoption + +拒绝。D-391 是 schema/table fact 授权,既有 adoption 是逐行首次接管协议;二者的冲突、幂等和 rollback 语义不等价。 + +### 为八个领域立即各建一个 package + +拒绝。尚未形成独立发布、依赖或生命周期边界,会制造单文件/浅层 package。领域 adapter 先在现有 owner composition 的嵌套目录孵化,只有 +出现稳定复用边界后才考虑拆包。 + +### 把全部 decision 加载成内存 JSON + +拒绝。Edge 路由设备不能让内存随 schema 规模增长;签名验证与 domain summary 必须流式、固定缓冲。 + +### 把 application plan 当作 restart authority + +拒绝。计划没有证明任何 adapter 已成功执行,也没有证明 Secret、历史或外部资产已收敛。 + +## 验收条件 + +1. 只有 exact reviewed head/review digest 可建立唯一 application fence;第二 application、rollback 与 restart 被阻断。 +2. signed authorization 被重新验证并汇总为固定八领域、双数据库、固定 disposition counters;全局与领域计数严格守恒。 +3. Legacy/Target 的方向矛盾 disposition 在签名前失败;blocked/informational 规则不回退。 +4. application plan/receipt 不包含名称、路径、fact digest、row value、command、Secret 或 reviewer identity,且 plan 不超过 64 KiB。 +5. prepare、plan、receipt、seal、head 的全部 response-loss 窗口 exact replay,竞争 application 失败关闭。 +6. terminal 文件为 `0400`、目录为 `0500`;verify 只读且不打开 SQLite、不写数据库、不调用服务或网络。 +7. 不新增 package/dependency/binary/常驻对象,不平铺 package `src/`;Edge 基础 closure 不增长。 +8. 聚焦、Local Owner、tracked backend、18-package、架构、Docker 与十四档 artifact 门通过后,ADR 才可改为 Accepted 并阶段提交。 + +## 未包含 + +- Automation 或其他领域的行级转换、SQL/DML、备份与 rollback; +- Secret 解密/重加密、Run history 合并、Plugin/AI 外部资产复制; +- reconciliation completion、target restart 或 Legacy source 删除; +- Cluster/PostgreSQL/Kubernetes reconciliation; +- 固定物理 Edge/NAS 的断电、FTL 写放大与迁移峰值证明。 diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts index 905255a8..f98bc73c 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts @@ -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 { 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') || diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 5133c793..9a7589f5 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -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, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts index b00b6fa1..abdc2dcd 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts @@ -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 --command-file /absolute/private-command.json'; + 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -76,6 +79,9 @@ async function main(argv: readonly string[]): Promise { 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 { ? 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' diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/contract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/contract.ts new file mode 100644 index 00000000..942586ea --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/contract.ts @@ -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; + 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; + 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; + 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 { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function 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 { + 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; + request: Record; +}> { + 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 { + 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 { + 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 { + 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', + ), + }), + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/coordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/coordinator.ts new file mode 100644 index 00000000..8a9a2afd --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/coordinator.ts @@ -0,0 +1,1010 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; + +import { currentIdentity } from '../../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../../foundation/error'; +import { + ensurePrivateDirectory, + preflightPublishedFile, + publishExactFile, + validatePrivateDirectory, +} from '../../foundation/files'; +import { + advanceLocalCutoverInstanceHead, + readLocalCutoverInstanceHead, + type LocalCutoverInstanceHead, +} from '../../cutover/instanceLineage'; +import { cutoverDigest } from '../../cutover/targetEvidence'; +import { readLocalReconciliationReviewTerminal } from '../review/completion'; +import type { LocalReconciliationReviewTerminal } from '../review/completion'; +import { + normalizeLocalReconciliationApplicationCommitCommand, + normalizeLocalReconciliationApplicationPrepareCommand, + normalizeLocalReconciliationApplicationVerifyCommand, + type LocalReconciliationApplicationCommitCommand, + type LocalReconciliationApplicationPrepareCommand, + type LocalReconciliationApplicationPrepareResult, + type LocalReconciliationApplicationTerminalResult, +} from './contract'; +import { + buildLocalReconciliationApplicationPlan, + buildLocalReconciliationApplicationPlanReceipt, + normalizeLocalReconciliationApplicationPlan, + normalizeLocalReconciliationApplicationPlanReceipt, + type LocalReconciliationApplicationPlan, + type LocalReconciliationApplicationPlanReceipt, +} from './plan'; + +const INTENT_SCHEMA = 'qinglong3-local-reconciliation-application-intent'; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const MAX_APPLICATIONS = 64; +const MAX_TERMINAL_BYTES = 64 * 1024; + +export interface LocalReconciliationApplicationIntent { + readonly schema: typeof INTENT_SCHEMA; + readonly schemaVersion: 1; + readonly state: 'reconciliation_application_prepared'; + readonly command: Readonly; + readonly profile: 'edge' | 'standalone'; + readonly instanceId: string; + readonly cutoverId: string; + readonly generation: number; + readonly activationDigest: string; + readonly reviewDigest: string; + readonly authorizationDigest: string; + readonly decisionSetDigest: string; + readonly decisionCount: number; + readonly reviewedHeadDigest: string; + readonly preparationDigest: string; +} + +export interface LocalReconciliationApplicationDependencies { + readonly afterHeadPrepared?: () => void; + readonly afterPlanPublished?: () => void; + readonly afterReceiptPublished?: () => void; + readonly afterTerminalSealed?: () => void; + readonly afterHeadAdvanced?: () => void; +} + +interface ApplicationPaths { + readonly root: string; + readonly staging: string; + readonly intent: string; + readonly plan: string; + readonly receipt: string; +} + +interface TerminalApplication { + readonly plan: Readonly; + readonly receipt: Readonly; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError(message, { cause }); +} + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function contents(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +export function localReconciliationApplicationDirectory( + applicationRoot: string, + applicationId: string, +): string { + return path.join(applicationRoot, applicationId); +} + +function applicationPaths( + applicationRoot: string, + applicationId: string, +): Readonly { + const root = localReconciliationApplicationDirectory( + applicationRoot, + applicationId, + ); + return Object.freeze({ + root, + staging: path.join(root, 'staging'), + intent: path.join(root, 'intent.json'), + plan: path.join(root, 'plan.json'), + receipt: path.join(root, 'receipt.json'), + }); +} + +function validateDirectory( + directory: string, + uid: number, + modes: readonly number[], + label: string, +): number { + let stat: fs.Stats; + try { + stat = fs.lstatSync(directory); + } catch (error) { + return configurationError(`${label} is unavailable`, error); + } + const mode = stat.mode & 0o777; + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== uid || + !modes.includes(mode) || + fs.realpathSync(directory) !== directory + ) { + configurationError(`${label} identity is invalid`); + } + return mode; +} + +function ensureApplicationDirectory( + applicationRoot: string, + applicationId: string, + uid: number, +): Readonly { + const selected = applicationPaths(applicationRoot, applicationId); + const entries = fs.readdirSync(applicationRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + configurationError('reconciliation application catalog contains drift'); + } + } + if (entries.length >= MAX_APPLICATIONS && !fs.existsSync(selected.root)) { + configurationError('reconciliation application retention limit is reached'); + } + ensurePrivateDirectory( + selected.root, + uid, + 'reconciliationApplicationDirectory', + ); + ensurePrivateDirectory( + selected.staging, + uid, + 'reconciliationApplicationStaging', + ); + return selected; +} + +function validateCatalog( + selected: Readonly, + terminal: boolean, +): void { + const allowed = new Set([ + 'intent.json', + 'plan.json', + 'receipt.json', + 'staging', + ...(!terminal + ? [ + '.intent.json.ql3-deploy-stage', + '.plan.json.ql3-deploy-stage', + '.receipt.json.ql3-deploy-stage', + ] + : []), + ]); + for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) { + if (!allowed.has(entry.name) || entry.isSymbolicLink()) { + configurationError( + 'reconciliation application root contains unknown material', + ); + } + } + if (fs.readdirSync(selected.staging).length !== 0) { + configurationError( + 'reconciliation application staging contains unknown material', + ); + } +} + +function terminalJson( + filePath: string, + uid: number, + allowedModes: readonly number[], +): unknown { + let descriptor: number | undefined; + let bytes: Buffer | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + !allowedModes.includes(Number(before.mode) & 0o777) || + before.nlink !== 1n || + before.size < 2n || + before.size > BigInt(MAX_TERMINAL_BYTES) + ) { + configurationError( + 'reconciliation application terminal file identity is invalid', + ); + } + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size + ) { + configurationError('application terminal file changed while opening'); + } + bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + const pathAfter = fs.lstatSync(filePath, { bigint: true }); + if ( + after.dev !== before.dev || + after.ino !== before.ino || + after.size !== before.size || + pathAfter.dev !== before.dev || + pathAfter.ino !== before.ino || + pathAfter.size !== before.size || + pathAfter.mtimeNs !== before.mtimeNs || + pathAfter.ctimeNs !== before.ctimeNs || + pathAfter.mode !== before.mode || + pathAfter.nlink !== before.nlink + ) { + configurationError('application terminal file changed while reading'); + } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError( + 'application terminal file cannot be read', + error, + ); + } finally { + bytes?.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +export function normalizeLocalReconciliationApplicationIntent( + value: unknown, +): Readonly { + const intent = object(value, 'reconciliation application intent'); + exact( + intent, + [ + 'activationDigest', + 'authorizationDigest', + 'command', + 'cutoverId', + 'decisionCount', + 'decisionSetDigest', + 'generation', + 'instanceId', + 'preparationDigest', + 'profile', + 'reviewDigest', + 'reviewedHeadDigest', + 'schema', + 'schemaVersion', + 'state', + ], + 'reconciliation application intent', + ); + const normalizedCommand = + normalizeLocalReconciliationApplicationPrepareCommand(intent.command); + const { preparationDigest, ...payload } = intent; + if ( + intent.schema !== INTENT_SCHEMA || + intent.schemaVersion !== 1 || + intent.state !== 'reconciliation_application_prepared' || + (intent.profile !== 'edge' && intent.profile !== 'standalone') || + typeof intent.instanceId !== 'string' || + intent.instanceId.length < 1 || + intent.instanceId.length > 128 || + typeof intent.cutoverId !== 'string' || + intent.cutoverId.length < 1 || + intent.cutoverId.length > 128 || + !Number.isSafeInteger(intent.generation) || + (intent.generation as number) < 1 || + !Number.isSafeInteger(intent.decisionCount) || + (intent.decisionCount as number) < 0 || + [ + intent.activationDigest, + intent.reviewDigest, + intent.authorizationDigest, + intent.decisionSetDigest, + intent.reviewedHeadDigest, + preparationDigest, + ].some( + (candidate) => + typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate), + ) || + cutoverDigest(payload) !== preparationDigest + ) { + configurationError('reconciliation application intent drifted'); + } + return Object.freeze({ + ...(intent as unknown as LocalReconciliationApplicationIntent), + command: normalizedCommand, + }); +} + +export function readLocalReconciliationApplicationIntent( + applicationRoot: string, + applicationId: string, +): Readonly { + const uid = currentIdentity().uid; + return normalizeLocalReconciliationApplicationIntent( + terminalJson( + applicationPaths(applicationRoot, applicationId).intent, + uid, + [0o600, 0o400], + ), + ); +} + +async function readReview( + command: Readonly< + | LocalReconciliationApplicationPrepareCommand + | LocalReconciliationApplicationCommitCommand + >, + reviewId: string, + uid: number, +): Promise> { + const terminal = await readLocalReconciliationReviewTerminal( + command.options.reviewRoot, + reviewId, + command.options.issuerKeyringPath, + uid, + ); + if ( + terminal.intent.command.options.deploymentRoot !== + command.options.deploymentRoot || + terminal.intent.command.options.captureRoot !== + command.options.captureRoot || + terminal.intent.command.options.planRoot !== command.options.planRoot || + terminal.intent.command.options.reviewRoot !== command.options.reviewRoot || + terminal.intent.command.options.allowRootService !== + command.options.allowRootService + ) { + configurationError( + 'reconciliation application is detached from review roots', + ); + } + return terminal; +} + +function buildIntent( + command: Readonly, + terminal: Readonly, +): Readonly { + if ( + terminal.review.reviewId !== command.request.reviewId || + terminal.review.reviewDigest !== command.request.expectedReviewDigest || + terminal.authorization.authorizationDigest !== + terminal.review.authorizationDigest || + command.request.preparedAtMs < terminal.review.committedAtMs + ) { + configurationError( + 'reconciliation application is detached from its review', + ); + } + const payload = Object.freeze({ + schema: INTENT_SCHEMA, + schemaVersion: 1 as const, + state: 'reconciliation_application_prepared' as const, + command, + profile: terminal.intent.profile, + instanceId: terminal.intent.instanceId, + cutoverId: terminal.intent.cutoverId, + generation: terminal.intent.generation, + activationDigest: terminal.intent.activationDigest, + reviewDigest: terminal.review.reviewDigest, + authorizationDigest: terminal.authorization.authorizationDigest, + decisionSetDigest: terminal.authorization.decisionSetDigest, + decisionCount: terminal.authorization.decisionCount, + reviewedHeadDigest: command.request.expectedHeadDigest, + }); + return Object.freeze({ + ...payload, + preparationDigest: cutoverDigest(payload), + }); +} + +function validateHeadIdentity( + head: Readonly, + intent: Readonly, +): void { + if ( + head.profile !== intent.profile || + head.cutoverId !== intent.cutoverId || + head.activationDigest !== intent.activationDigest || + head.generation !== intent.generation + ) { + configurationError( + 'reconciliation application instance head identity drifted', + ); + } +} + +function advanceApplicationHead( + intent: Readonly, + uid: number, + state: + | 'reconciliation_application_prepared' + | 'reconciliation_application_planned', + requestedAtMs: number, + sourceRecordDigest: string, +): Readonly { + return advanceLocalCutoverInstanceHead( + { + options: { deploymentRoot: intent.command.options.deploymentRoot }, + request: { + cutoverId: intent.cutoverId, + profile: intent.profile, + instanceId: intent.instanceId, + expectedActivationDigest: intent.activationDigest, + requestedAtMs, + }, + }, + uid, + state, + intent.generation, + sourceRecordDigest, + ); +} + +export async function prepareLocalReconciliationApplication( + input: unknown, + dependencies: LocalReconciliationApplicationDependencies = {}, +): Promise> { + const command = normalizeLocalReconciliationApplicationPrepareCommand(input); + const identity = currentIdentity(); + validatePrivateDirectory( + command.options.deploymentRoot, + identity.uid, + 'deploymentRoot', + ); + validatePrivateDirectory( + command.options.captureRoot, + identity.uid, + 'captureRoot', + ); + validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot'); + validatePrivateDirectory( + command.options.reviewRoot, + identity.uid, + 'reviewRoot', + ); + validatePrivateDirectory( + command.options.applicationRoot, + identity.uid, + 'applicationRoot', + ); + const review = await readReview( + command, + command.request.reviewId, + identity.uid, + ); + const intent = buildIntent(command, review); + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + identity.uid, + ); + validateHeadIdentity(head, intent); + if ( + (head.state === 'reconciliation_reviewed' && + (head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== intent.reviewDigest)) || + (head.state === 'reconciliation_application_prepared' && + head.sourceRecordDigest !== intent.preparationDigest) || + (head.state !== 'reconciliation_reviewed' && + head.state !== 'reconciliation_application_prepared') + ) { + configurationError( + 'application prepare lost reviewed head compare-and-swap', + ); + } + const selected = ensureApplicationDirectory( + command.options.applicationRoot, + command.request.applicationId, + identity.uid, + ); + const serialized = contents(intent); + preflightPublishedFile( + selected.intent, + serialized, + 0o600, + identity.uid, + 'reconciliation application intent', + ); + const nextHead = + head.state === 'reconciliation_application_prepared' + ? head + : advanceApplicationHead( + intent, + identity.uid, + 'reconciliation_application_prepared', + command.request.preparedAtMs, + intent.preparationDigest, + ); + dependencies.afterHeadPrepared?.(); + const status = publishExactFile( + selected.intent, + serialized, + 0o600, + identity.uid, + 'reconciliation application intent', + ); + validateCatalog(selected, false); + return Object.freeze({ + schemaVersion: 1, + operation: command.operation, + status, + state: 'reconciliation_application_prepared', + applicationId: command.request.applicationId, + preparationDigest: intent.preparationDigest, + instanceHeadDigest: nextHead.headDigest, + }); +} + +function validateCommitBinding( + command: Readonly, + intent: Readonly, +): void { + const prepared = intent.command; + if ( + prepared.options.deploymentRoot !== command.options.deploymentRoot || + prepared.options.captureRoot !== command.options.captureRoot || + prepared.options.planRoot !== command.options.planRoot || + prepared.options.reviewRoot !== command.options.reviewRoot || + prepared.options.applicationRoot !== command.options.applicationRoot || + prepared.options.issuerKeyringPath !== command.options.issuerKeyringPath || + prepared.options.allowRootService !== command.options.allowRootService || + prepared.request.applicationId !== command.request.applicationId || + intent.preparationDigest !== command.request.expectedPreparationDigest || + command.request.committedAtMs < prepared.request.preparedAtMs + ) { + configurationError('application commit is detached from preparation'); + } +} + +function validateReviewBinding( + intent: Readonly, + terminal: Readonly, +): void { + if ( + terminal.review.reviewId !== intent.command.request.reviewId || + terminal.review.reviewDigest !== intent.reviewDigest || + terminal.authorization.authorizationDigest !== intent.authorizationDigest || + terminal.authorization.decisionSetDigest !== intent.decisionSetDigest || + terminal.authorization.decisionCount !== intent.decisionCount + ) { + configurationError('application lost its signed review binding'); + } +} + +function validateTerminalBinding( + intent: Readonly, + review: Readonly, + terminal: Readonly, +): void { + const { plan, receipt } = terminal; + if ( + plan.applicationId !== intent.command.request.applicationId || + plan.reviewId !== intent.command.request.reviewId || + plan.profile !== intent.profile || + plan.preparationDigest !== intent.preparationDigest || + plan.reviewDigest !== intent.reviewDigest || + plan.authorizationDigest !== intent.authorizationDigest || + plan.decisionSetDigest !== intent.decisionSetDigest || + plan.decisionCount !== intent.decisionCount || + receipt.applicationId !== plan.applicationId || + receipt.reviewId !== plan.reviewId || + receipt.preparationDigest !== plan.preparationDigest || + receipt.preparedHeadDigest !== plan.preparedHeadDigest || + receipt.reviewDigest !== plan.reviewDigest || + receipt.authorizationDigest !== plan.authorizationDigest || + receipt.decisionSetDigest !== plan.decisionSetDigest || + receipt.decisionCount !== plan.decisionCount || + receipt.applicationPlanDigest !== plan.applicationPlanDigest || + receipt.outcome !== plan.outcome || + receipt.committedAtMs !== plan.committedAtMs || + review.review.reviewDigest !== plan.reviewDigest || + review.authorization.authorizationDigest !== plan.authorizationDigest || + review.authorization.decisionSetDigest !== plan.decisionSetDigest || + review.authorization.decisionCount !== plan.decisionCount + ) { + configurationError('terminal reconciliation application binding drifted'); + } +} + +function readTerminalApplication( + selected: Readonly, + intent: Readonly, + review: Readonly, + uid: number, + allowedModes: readonly number[], +): Readonly { + const plan = normalizeLocalReconciliationApplicationPlan( + terminalJson(selected.plan, uid, allowedModes), + ); + const receipt = normalizeLocalReconciliationApplicationPlanReceipt( + terminalJson(selected.receipt, uid, allowedModes), + ); + const terminal = Object.freeze({ plan, receipt }); + validateTerminalBinding(intent, review, terminal); + return terminal; +} + +function sealFile(filePath: string, uid: number): void { + let descriptor: number | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + ![0o600, 0o400].includes(Number(before.mode) & 0o777) || + before.nlink !== 1n + ) { + configurationError('application terminal file cannot be sealed'); + } + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size + ) { + configurationError('application terminal file changed while sealing'); + } + if ((Number(opened.mode) & 0o777) !== 0o400) { + fs.fchmodSync(descriptor, 0o400); + } + fs.fsyncSync(descriptor); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function sealDirectory(directory: string, uid: number): void { + const mode = validateDirectory( + directory, + uid, + [0o700, 0o500], + 'application terminal directory', + ); + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY); + try { + if (mode !== 0o500) fs.fchmodSync(descriptor, 0o500); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function sealTerminal(selected: Readonly, uid: number): void { + if (fs.readdirSync(selected.staging).length !== 0) { + configurationError( + 'application staging must be empty before terminal seal', + ); + } + for (const filePath of [selected.intent, selected.plan, selected.receipt]) { + sealFile(filePath, uid); + } + sealDirectory(selected.staging, uid); + sealDirectory(selected.root, uid); + validateCatalog(selected, true); +} + +function result( + operation: LocalReconciliationApplicationTerminalResult['operation'], + status: LocalReconciliationApplicationTerminalResult['status'], + terminal: Readonly, + head: Readonly, +): Readonly { + return Object.freeze({ + schemaVersion: 1, + operation, + status, + state: 'reconciliation_application_planned', + applicationId: terminal.plan.applicationId, + applicationPlanDigest: terminal.plan.applicationPlanDigest, + outcome: terminal.plan.outcome, + domainCount: 8, + instanceHeadDigest: head.headDigest, + }); +} + +export async function commitLocalReconciliationApplication( + input: unknown, + dependencies: LocalReconciliationApplicationDependencies = {}, +): Promise> { + const command = normalizeLocalReconciliationApplicationCommitCommand(input); + const identity = currentIdentity(); + for (const [directory, label] of [ + [command.options.deploymentRoot, 'deploymentRoot'], + [command.options.captureRoot, 'captureRoot'], + [command.options.planRoot, 'planRoot'], + [command.options.reviewRoot, 'reviewRoot'], + [command.options.applicationRoot, 'applicationRoot'], + ] as const) { + validatePrivateDirectory(directory, identity.uid, label); + } + const selected = applicationPaths( + command.options.applicationRoot, + command.request.applicationId, + ); + validateDirectory( + selected.root, + identity.uid, + [0o700, 0o500], + 'reconciliationApplicationDirectory', + ); + validateDirectory( + selected.staging, + identity.uid, + [0o700, 0o500], + 'reconciliationApplicationStaging', + ); + const intent = readLocalReconciliationApplicationIntent( + command.options.applicationRoot, + command.request.applicationId, + ); + validateCommitBinding(command, intent); + const review = await readReview( + command, + intent.command.request.reviewId, + identity.uid, + ); + validateReviewBinding(intent, review); + let head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + identity.uid, + ); + validateHeadIdentity(head, intent); + if (fs.existsSync(selected.receipt)) { + const terminal = readTerminalApplication( + selected, + intent, + review, + identity.uid, + [0o600, 0o400], + ); + if ( + terminal.plan.committedAtMs !== command.request.committedAtMs || + terminal.plan.preparedHeadDigest !== command.request.expectedHeadDigest || + (head.state !== 'reconciliation_application_prepared' && + head.state !== 'reconciliation_application_planned') || + (head.state === 'reconciliation_application_prepared' && + (head.headDigest !== terminal.plan.preparedHeadDigest || + head.sourceRecordDigest !== intent.preparationDigest)) || + (head.state === 'reconciliation_application_planned' && + head.sourceRecordDigest !== terminal.plan.applicationPlanDigest) + ) { + configurationError('terminal application lost instance head binding'); + } + sealTerminal(selected, identity.uid); + dependencies.afterTerminalSealed?.(); + const existing = head.state === 'reconciliation_application_planned'; + head = existing + ? head + : advanceApplicationHead( + intent, + identity.uid, + 'reconciliation_application_planned', + terminal.plan.committedAtMs, + terminal.plan.applicationPlanDigest, + ); + dependencies.afterHeadAdvanced?.(); + return result( + command.operation, + existing ? 'existing' : 'prepared', + terminal, + head, + ); + } + if ( + head.state !== 'reconciliation_application_prepared' || + head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError( + 'application commit lost prepared head compare-and-swap', + ); + } + validateCatalog(selected, false); + let plan: Readonly; + if (fs.existsSync(selected.plan)) { + plan = normalizeLocalReconciliationApplicationPlan( + terminalJson(selected.plan, identity.uid, [0o600]), + ); + if ( + plan.applicationId !== command.request.applicationId || + plan.preparationDigest !== intent.preparationDigest || + plan.preparedHeadDigest !== command.request.expectedHeadDigest || + plan.reviewDigest !== intent.reviewDigest || + plan.committedAtMs !== command.request.committedAtMs + ) { + configurationError('published application plan lost its preparation'); + } + } else { + plan = buildLocalReconciliationApplicationPlan( + intent, + review.authorization, + command.request.committedAtMs, + command.request.expectedHeadDigest, + ); + publishExactFile( + selected.plan, + contents(plan), + 0o600, + identity.uid, + 'reconciliation application plan', + ); + dependencies.afterPlanPublished?.(); + } + const receipt = buildLocalReconciliationApplicationPlanReceipt(plan); + publishExactFile( + selected.receipt, + contents(receipt), + 0o600, + identity.uid, + 'reconciliation application receipt', + ); + dependencies.afterReceiptPublished?.(); + const terminal = readTerminalApplication( + selected, + intent, + review, + identity.uid, + [0o600], + ); + sealTerminal(selected, identity.uid); + dependencies.afterTerminalSealed?.(); + head = advanceApplicationHead( + intent, + identity.uid, + 'reconciliation_application_planned', + plan.committedAtMs, + plan.applicationPlanDigest, + ); + dependencies.afterHeadAdvanced?.(); + return result(command.operation, 'prepared', terminal, head); +} + +export async function verifyLocalReconciliationApplication( + input: unknown, +): Promise> { + const command = normalizeLocalReconciliationApplicationVerifyCommand(input); + const identity = currentIdentity(); + for (const [directory, label] of [ + [command.options.deploymentRoot, 'deploymentRoot'], + [command.options.captureRoot, 'captureRoot'], + [command.options.planRoot, 'planRoot'], + [command.options.reviewRoot, 'reviewRoot'], + [command.options.applicationRoot, 'applicationRoot'], + ] as const) { + validatePrivateDirectory(directory, identity.uid, label); + } + const selected = applicationPaths( + command.options.applicationRoot, + command.request.applicationId, + ); + validateDirectory( + selected.root, + identity.uid, + [0o500], + 'reconciliationApplicationDirectory', + ); + validateDirectory( + selected.staging, + identity.uid, + [0o500], + 'reconciliationApplicationStaging', + ); + validateCatalog(selected, true); + const intent = readLocalReconciliationApplicationIntent( + command.options.applicationRoot, + command.request.applicationId, + ); + const prepared = intent.command.options; + if ( + prepared.deploymentRoot !== command.options.deploymentRoot || + prepared.captureRoot !== command.options.captureRoot || + prepared.planRoot !== command.options.planRoot || + prepared.reviewRoot !== command.options.reviewRoot || + prepared.applicationRoot !== command.options.applicationRoot || + prepared.issuerKeyringPath !== command.options.issuerKeyringPath || + prepared.allowRootService !== command.options.allowRootService + ) { + configurationError('application verify is detached from preparation'); + } + const review = await readLocalReconciliationReviewTerminal( + command.options.reviewRoot, + intent.command.request.reviewId, + command.options.issuerKeyringPath, + identity.uid, + ); + validateReviewBinding(intent, review); + const terminal = readTerminalApplication( + selected, + intent, + review, + identity.uid, + [0o400], + ); + if ( + terminal.plan.applicationPlanDigest !== + command.request.expectedApplicationPlanDigest + ) { + configurationError('application verify expected digest drifted'); + } + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + identity.uid, + ); + validateHeadIdentity(head, intent); + if ( + head.state !== 'reconciliation_application_planned' || + head.sourceRecordDigest !== terminal.plan.applicationPlanDigest + ) { + configurationError('application verify lost terminal instance head'); + } + return result(command.operation, 'verified', terminal, head); +} + +export async function prepareLocalReconciliationApplicationCommandFile( + filePath: string, +): Promise> { + return prepareLocalReconciliationApplication( + readPrivateLocalCommandFile(filePath), + ); +} + +export async function commitLocalReconciliationApplicationCommandFile( + filePath: string, +): Promise> { + return commitLocalReconciliationApplication( + readPrivateLocalCommandFile(filePath), + ); +} + +export async function verifyLocalReconciliationApplicationCommandFile( + filePath: string, +): Promise> { + return verifyLocalReconciliationApplication( + readPrivateLocalCommandFile(filePath), + ); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/plan.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/plan.ts new file mode 100644 index 00000000..5dabe509 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/plan.ts @@ -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 + >; +} + +export interface LocalReconciliationApplicationDomainSummary { + readonly domain: LocalReconciliationPlanDomain; + readonly legacy: Readonly; + readonly target: Readonly; + 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[]; + 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 { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function safeCount(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function counts( + value: unknown, + label: string, +): Readonly> { + 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 + >; +} + +function sumCounts( + selected: Readonly>, +): 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>, +): Readonly { + const dispositionCounts = Object.freeze({ ...selected }); + return Object.freeze({ + decisionCount: sumCounts(dispositionCounts), + dispositionCounts, + }); +} + +function action( + selected: Readonly, +): 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[], +): 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, + authorization: Readonly, + committedAtMs: number, + preparedHeadDigest: string, +): Readonly { + 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 { + 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; +} + +function normalizeDomain( + value: unknown, + expectedDomain: LocalReconciliationPlanDomain, +): Readonly { + 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; +} + +export function normalizeLocalReconciliationApplicationPlan( + value: unknown, +): Readonly { + 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, +): Readonly { + 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 { + 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; +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/authorization.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/authorization.ts index 67e5c91d..56a32019 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/authorization.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/authorization.ts @@ -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 >; + readonly domainDecisionCounts: readonly Readonly[]; readonly header: Readonly; } +export interface LocalReconciliationReviewAuthorizationDomainDecisionCounts { + readonly domain: LocalReconciliationPlanDomain; + readonly legacy: Readonly< + Record + >; + readonly target: Readonly< + Record + >; +} + interface SignatureRecord { readonly schemaVersion: 1; readonly kind: typeof SIGNATURE_KIND; @@ -97,6 +112,32 @@ function zeroCounts(keys: readonly T[]): Record { return Object.fromEntries(keys.map((key) => [key, 0])) as Record; } +function zeroDomainDecisionCounts(): Array<{ + domain: LocalReconciliationPlanDomain; + legacy: Record; + target: Record; +}> { + return LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain) => ({ + domain, + legacy: zeroCounts(DISPOSITIONS), + target: zeroCounts(DISPOSITIONS), + })); +} + +function freezeDomainDecisionCounts( + counts: ReturnType, +): readonly Readonly[] { + 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) { diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts index bb62da62..95ae5f68 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts @@ -504,6 +504,50 @@ async function readTerminal( return Object.freeze({ authorization, review, receipt }); } +export interface LocalReconciliationReviewTerminal { + readonly intent: Readonly; + readonly authorization: Readonly; + readonly review: Readonly; + readonly receipt: Readonly; +} + +export async function readLocalReconciliationReviewTerminal( + reviewRoot: string, + reviewId: string, + issuerKeyringPath: string, + uid: number, +): Promise> { + 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'], diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/decisionFile.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/decisionFile.ts index 27db5171..83a380d4 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/decisionFile.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/decisionFile.ts @@ -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') || diff --git a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts index 6e0d48a8..afd07f3e 100644 --- a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts +++ b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts @@ -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 || diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index 86aaa613..a7033765 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -9,12 +9,15 @@ const { test } = require('node:test'); const { commitLocalReconciliationCapture, + commitLocalReconciliationApplication, commitLocalReconciliationPlan, commitLocalReconciliationReview, prepareLocalReconciliationCapture, + prepareLocalReconciliationApplication, prepareLocalReconciliationPlan, prepareLocalReconciliationReview, verifyLocalReconciliationCapture, + verifyLocalReconciliationApplication, verifyLocalReconciliationPlan, verifyLocalReconciliationReview, writeLocalReconciliationReviewDiagnostics, @@ -799,6 +802,7 @@ function writeReviewDecisionFile(state, prepared, fileName = 'review.ndjson') { for (const fact of page.records) { if (fact.decisionRequirement === 'informational') continue; const blocked = fact.decisionRequirement === 'blocked'; + const legacy = fact.database === 'legacy'; decisions.push({ schemaVersion: 1, kind: 'qinglong3-local-reconciliation-review-decision', @@ -807,9 +811,15 @@ function writeReviewDecisionFile(state, prepared, fileName = 'review.ndjson') { factKind: fact.factKind, ordinal: fact.ordinal, factDigest: fact.factDigest, - disposition: blocked ? 'manual_external' : 'retain_target', + disposition: blocked + ? 'manual_external' + : legacy + ? 'exclude_legacy' + : 'retain_target', reason: blocked ? 'external_recovery_required' + : legacy + ? 'legacy_excluded' : 'preserve_target', }); } @@ -924,6 +934,67 @@ function reviewCommitFixture(t, options = {}) { }; } +async function reviewedApplicationFixture(t, options = {}) { + const state = reviewCommitFixture(t, options); + options.mutateDecisions?.(state.reviewFile.records); + if (options.mutateDecisions) { + fs.writeFileSync( + state.reviewFile.filePath, + `${state.reviewFile.records + .map((record) => JSON.stringify(record)) + .join('\n')}\n`, + { mode: 0o600 }, + ); + } + const reviewed = await commitLocalReconciliationReview( + state.command, + state.dependencies, + ); + const applicationRoot = path.join( + path.dirname(state.captureRoot), + `application-root-${options.reviewSuffix ?? '1'}`, + ); + fs.mkdirSync(applicationRoot, { mode: 0o700 }); + const applicationId = + options.applicationId ?? '00000000-0000-4000-8000-000000000401'; + const prepareCommand = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.application.prepare', + options: { + ...state.reviewCommand.options, + applicationRoot, + issuerKeyringPath: state.issuerKeyringPath, + }, + request: { + applicationId, + reviewId: state.reviewCommand.request.reviewId, + expectedReviewDigest: reviewed.reviewDigest, + expectedHeadDigest: reviewed.instanceHeadDigest, + preparedAtMs: state.command.request.committedAtMs + 1, + }, + }; + return { + ...state, + reviewed, + applicationRoot, + prepareApplicationCommand: prepareCommand, + }; +} + +function applicationCommitCommand(state, prepared) { + return { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.application.commit', + options: state.prepareApplicationCommand.options, + request: { + applicationId: state.prepareApplicationCommand.request.applicationId, + expectedPreparationDigest: prepared.preparationDigest, + expectedHeadDigest: prepared.instanceHeadDigest, + committedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 1, + }, + }; +} + function dockerReadSealedSqlite(assetsDirectory, mode) { const source = mode === 'main_only_immutable' @@ -2157,6 +2228,35 @@ test('review commit rejects missing and policy-invalid decisions before terminal commitLocalReconciliationReview(blocked.command, blocked.dependencies), /not allowed for canonical fact/, ); + + const crossDatabase = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000390', + reviewId: '00000000-0000-4000-8000-000000000391', + reviewSuffix: 'cross-database', + }); + const legacyDecision = crossDatabase.reviewFile.records.find( + (record) => + record.kind === 'qinglong3-local-reconciliation-review-decision' && + record.database === 'legacy' && + record.disposition === 'exclude_legacy', + ); + assert.ok(legacyDecision); + legacyDecision.disposition = 'retain_target'; + legacyDecision.reason = 'preserve_target'; + fs.writeFileSync( + crossDatabase.reviewFile.filePath, + `${crossDatabase.reviewFile.records + .map((record) => JSON.stringify(record)) + .join('\n')}\n`, + { mode: 0o600 }, + ); + await assert.rejects( + commitLocalReconciliationReview( + crossDatabase.command, + crossDatabase.dependencies, + ), + /not allowed for canonical fact/, + ); }); test('review commit rejects weak principals, oversized Edge streams and decision-file drift around signing', async (t) => { @@ -2329,6 +2429,262 @@ test('review commit resumes authorization, receipt, seal and head response-loss assert.equal(headReplay.status, 'existing'); }); +test('application coordinator plans eight content-free domains and verifies without SQLite writes', async (t) => { + const state = await reviewedApplicationFixture(t, { + planId: '00000000-0000-4000-8000-000000000401', + reviewId: '00000000-0000-4000-8000-000000000402', + applicationId: '00000000-0000-4000-8000-000000000403', + reviewSuffix: 'application-terminal', + mutateDecisions(records) { + const selected = records.find( + (record) => + record.kind === 'qinglong3-local-reconciliation-review-decision' && + record.database === 'legacy' && + record.domain === 'automation' && + record.disposition === 'exclude_legacy', + ); + assert.ok(selected); + selected.disposition = 'adopt_legacy'; + selected.reason = 'prefer_legacy'; + }, + }); + const targetBefore = fs.statSync(state.targetDatabasePath, { bigint: true }); + const targetBytes = fs.readFileSync(state.targetDatabasePath); + const prepared = await prepareLocalReconciliationApplication( + state.prepareApplicationCommand, + ); + assert.equal(prepared.status, 'prepared'); + assert.equal(prepared.state, 'reconciliation_application_prepared'); + const commitCommand = applicationCommitCommand(state, prepared); + const committed = await commitLocalReconciliationApplication(commitCommand); + assert.equal(committed.status, 'prepared'); + assert.equal(committed.state, 'reconciliation_application_planned'); + assert.equal(committed.domainCount, 8); + assert.equal(committed.outcome, 'adapter_and_manual_required'); + const applicationDirectory = path.join( + state.applicationRoot, + state.prepareApplicationCommand.request.applicationId, + ); + assert.deepEqual(fs.readdirSync(applicationDirectory).sort(), [ + 'intent.json', + 'plan.json', + 'receipt.json', + 'staging', + ]); + assert.equal(fs.statSync(applicationDirectory).mode & 0o777, 0o500); + assert.equal( + fs.statSync(path.join(applicationDirectory, 'staging')).mode & 0o777, + 0o500, + ); + for (const fileName of ['intent.json', 'plan.json', 'receipt.json']) { + assert.equal( + fs.statSync(path.join(applicationDirectory, fileName)).mode & 0o777, + 0o400, + ); + } + const planText = fs.readFileSync( + path.join(applicationDirectory, 'plan.json'), + 'utf8', + ); + const plan = JSON.parse(planText); + assert.equal(plan.domains.length, 8); + assert.equal( + plan.domains.find((domain) => domain.domain === 'automation').action, + 'adapter_required', + ); + assert.equal(planText.includes('QingLong3TaskDefinitions'), false); + assert.equal(planText.includes('Crontabs'), false); + assert.equal(planText.includes('private-value'), false); + assert.equal(planText.includes('0 0 * * *'), false); + const verifyCommand = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.application.verify', + options: state.prepareApplicationCommand.options, + request: { + applicationId: state.prepareApplicationCommand.request.applicationId, + expectedApplicationPlanDigest: committed.applicationPlanDigest, + }, + }; + const verified = await verifyLocalReconciliationApplication(verifyCommand); + assert.equal(verified.status, 'verified'); + assert.equal(verified.applicationPlanDigest, committed.applicationPlanDigest); + const head = readLocalCutoverInstanceHead( + state.deploymentRoot, + state.captureCommand.request.instanceId, + state.uid, + ); + assert.equal(head.state, 'reconciliation_application_planned'); + assert.equal(head.sourceRecordDigest, committed.applicationPlanDigest); + const targetAfter = fs.statSync(state.targetDatabasePath, { bigint: true }); + assert.equal( + fs.readFileSync(state.targetDatabasePath).equals(targetBytes), + true, + ); + assert.equal(targetAfter.mtimeNs, targetBefore.mtimeNs); + assert.equal(targetAfter.ctimeNs, targetBefore.ctimeNs); + + const commandPath = path.join( + state.deploymentRoot, + 'application-verify.json', + ); + fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, { + mode: 0o600, + }); + const cli = spawnSync( + process.execPath, + [ + path.join(__dirname, '../dist/deployment/localDeploymentCli.js'), + 'reconciliation-application-verify', + '--command-file', + commandPath, + ], + { encoding: 'utf8' }, + ); + assert.equal(cli.status, 0, cli.stderr); + assert.equal(JSON.parse(cli.stdout).status, 'verified'); + assert.equal(cli.stdout.includes(state.applicationRoot), false); + assert.equal(cli.stdout.includes('review-owner'), false); + assert.equal(cli.stdout.includes('Crontabs'), false); +}); + +test('application coordinator resumes every publication window and fences competitors', async (t) => { + const prepareCrash = await reviewedApplicationFixture(t, { + planId: '00000000-0000-4000-8000-000000000410', + reviewId: '00000000-0000-4000-8000-000000000411', + applicationId: '00000000-0000-4000-8000-000000000412', + reviewSuffix: 'application-prepare-crash', + }); + await assert.rejects( + prepareLocalReconciliationApplication( + prepareCrash.prepareApplicationCommand, + { + afterHeadPrepared() { + throw new Error('application prepare head response loss'); + }, + }, + ), + /application prepare head response loss/, + ); + const preparedReplay = await prepareLocalReconciliationApplication( + prepareCrash.prepareApplicationCommand, + ); + assert.equal(preparedReplay.state, 'reconciliation_application_prepared'); + await assert.rejects( + prepareLocalReconciliationApplication({ + ...prepareCrash.prepareApplicationCommand, + request: { + ...prepareCrash.prepareApplicationCommand.request, + applicationId: '00000000-0000-4000-8000-00000000041f', + }, + }), + /lost reviewed head compare-and-swap/, + ); + + const windows = [ + ['plan', '000000000413', '000000000414', '000000000415'], + ['receipt', '000000000416', '000000000417', '000000000418'], + ['seal', '000000000419', '00000000041a', '00000000041b'], + ['head', '00000000041c', '00000000041d', '00000000041e'], + ]; + for (const [window, planTail, reviewTail, applicationTail] of windows) { + const state = await reviewedApplicationFixture(t, { + planId: `00000000-0000-4000-8000-${planTail}`, + reviewId: `00000000-0000-4000-8000-${reviewTail}`, + applicationId: `00000000-0000-4000-8000-${applicationTail}`, + reviewSuffix: `application-${window}-crash`, + }); + const prepared = await prepareLocalReconciliationApplication( + state.prepareApplicationCommand, + ); + const commitCommand = applicationCommitCommand(state, prepared); + const callback = + window === 'plan' + ? 'afterPlanPublished' + : window === 'receipt' + ? 'afterReceiptPublished' + : window === 'seal' + ? 'afterTerminalSealed' + : 'afterHeadAdvanced'; + await assert.rejects( + commitLocalReconciliationApplication(commitCommand, { + [callback]() { + throw new Error(`application ${window} response loss`); + }, + }), + new RegExp(`application ${window} response loss`), + ); + const replay = await commitLocalReconciliationApplication(commitCommand); + assert.equal(replay.state, 'reconciliation_application_planned'); + if (window === 'head') assert.equal(replay.status, 'existing'); + } +}); + +test('application verify rejects digest and head drift without repairing state', async (t) => { + const state = await reviewedApplicationFixture(t, { + planId: '00000000-0000-4000-8000-000000000421', + reviewId: '00000000-0000-4000-8000-000000000422', + applicationId: '00000000-0000-4000-8000-000000000423', + reviewSuffix: 'application-verify-drift', + }); + const prepared = await prepareLocalReconciliationApplication( + state.prepareApplicationCommand, + ); + const committed = await commitLocalReconciliationApplication( + applicationCommitCommand(state, prepared), + ); + const verifyCommand = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.application.verify', + options: state.prepareApplicationCommand.options, + request: { + applicationId: state.prepareApplicationCommand.request.applicationId, + expectedApplicationPlanDigest: 'f'.repeat(64), + }, + }; + const applicationDirectory = path.join( + state.applicationRoot, + state.prepareApplicationCommand.request.applicationId, + ); + const before = fs.statSync(path.join(applicationDirectory, 'plan.json'), { + bigint: true, + }); + await assert.rejects( + verifyLocalReconciliationApplication(verifyCommand), + /expected digest drifted/, + ); + const after = fs.statSync(path.join(applicationDirectory, 'plan.json'), { + bigint: true, + }); + assert.equal(after.mtimeNs, before.mtimeNs); + assert.equal(after.ctimeNs, before.ctimeNs); + assert.notEqual(committed.applicationPlanDigest, 'f'.repeat(64)); + + const headPath = path.join( + state.deploymentRoot, + 'service', + 'cutover-instances', + state.captureCommand.request.instanceId, + 'head.json', + ); + const head = JSON.parse(fs.readFileSync(headPath, 'utf8')); + delete head.headDigest; + head.sourceRecordDigest = 'e'.repeat(64); + head.headDigest = digest(head); + fs.writeFileSync(headPath, `${JSON.stringify(head, null, 2)}\n`, { + mode: 0o600, + }); + await assert.rejects( + verifyLocalReconciliationApplication({ + ...verifyCommand, + request: { + ...verifyCommand.request, + expectedApplicationPlanDigest: committed.applicationPlanDigest, + }, + }), + /lost terminal instance head/, + ); +}); + test( 'real stopped Docker target produces an independently verified bundle', { skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' }, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index ce63c90b..e7979048 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localOwnerCli.rootSourceFileRoles, }, { - sourceFiles: 155, + sourceFiles: 158, rootSourceFiles: 1, rootSourceLines: 50, - nestedSourceFiles: 154, + nestedSourceFiles: 157, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );