mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): plan reconciliation application
This commit is contained in:
@@ -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 每次只把一个
|
||||
|
||||
@@ -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 写放大与迁移峰值证明。
|
||||
@@ -29,6 +29,8 @@ export type LocalCutoverInstanceHeadState =
|
||||
| 'reconciliation_planned'
|
||||
| 'reconciliation_review_prepared'
|
||||
| 'reconciliation_reviewed'
|
||||
| 'reconciliation_application_prepared'
|
||||
| 'reconciliation_application_planned'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -166,6 +168,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
head.state !== 'reconciliation_planned' &&
|
||||
head.state !== 'reconciliation_review_prepared' &&
|
||||
head.state !== 'reconciliation_reviewed' &&
|
||||
head.state !== 'reconciliation_application_prepared' &&
|
||||
head.state !== 'reconciliation_application_planned' &&
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested' &&
|
||||
head.state !== 'legacy_running' &&
|
||||
@@ -338,6 +342,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
| 'reconciliation_planned'
|
||||
| 'reconciliation_review_prepared'
|
||||
| 'reconciliation_reviewed'
|
||||
| 'reconciliation_application_prepared'
|
||||
| 'reconciliation_application_planned'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -382,6 +388,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_planned' ||
|
||||
current.state === 'reconciliation_review_prepared' ||
|
||||
current.state === 'reconciliation_reviewed' ||
|
||||
current.state === 'reconciliation_application_prepared' ||
|
||||
current.state === 'reconciliation_application_planned' ||
|
||||
current.state === 'legacy_restart_requested' ||
|
||||
current.state === 'legacy_running' ||
|
||||
current.state === 'legacy_ready')
|
||||
@@ -409,6 +417,10 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_planned') ||
|
||||
(state === 'reconciliation_reviewed' &&
|
||||
current.state === 'reconciliation_review_prepared') ||
|
||||
(state === 'reconciliation_application_prepared' &&
|
||||
current.state === 'reconciliation_reviewed') ||
|
||||
(state === 'reconciliation_application_planned' &&
|
||||
current.state === 'reconciliation_application_prepared') ||
|
||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||
(state === 'legacy_restart_requested' &&
|
||||
current.state === 'rollback_prepared') ||
|
||||
|
||||
@@ -123,6 +123,14 @@ import {
|
||||
verifyLocalReconciliationReview,
|
||||
verifyLocalReconciliationReviewCommandFile,
|
||||
} from './reconciliation/review/completion';
|
||||
import {
|
||||
commitLocalReconciliationApplication,
|
||||
commitLocalReconciliationApplicationCommandFile,
|
||||
prepareLocalReconciliationApplication,
|
||||
prepareLocalReconciliationApplicationCommandFile,
|
||||
verifyLocalReconciliationApplication,
|
||||
verifyLocalReconciliationApplicationCommandFile,
|
||||
} from './reconciliation/application/coordinator';
|
||||
|
||||
export {
|
||||
commitLocalReconciliationPlan,
|
||||
@@ -139,8 +147,42 @@ export {
|
||||
commitLocalReconciliationReviewCommandFile,
|
||||
verifyLocalReconciliationReview,
|
||||
verifyLocalReconciliationReviewCommandFile,
|
||||
prepareLocalReconciliationApplication,
|
||||
prepareLocalReconciliationApplicationCommandFile,
|
||||
commitLocalReconciliationApplication,
|
||||
commitLocalReconciliationApplicationCommandFile,
|
||||
verifyLocalReconciliationApplication,
|
||||
verifyLocalReconciliationApplicationCommandFile,
|
||||
};
|
||||
|
||||
export {
|
||||
normalizeLocalReconciliationApplicationCommitCommand,
|
||||
normalizeLocalReconciliationApplicationPrepareCommand,
|
||||
normalizeLocalReconciliationApplicationVerifyCommand,
|
||||
type LocalReconciliationApplicationCommitCommand,
|
||||
type LocalReconciliationApplicationOptions,
|
||||
type LocalReconciliationApplicationPrepareCommand,
|
||||
type LocalReconciliationApplicationPrepareResult,
|
||||
type LocalReconciliationApplicationTerminalResult,
|
||||
type LocalReconciliationApplicationVerifyCommand,
|
||||
} from './reconciliation/application/contract';
|
||||
export {
|
||||
localReconciliationApplicationDirectory,
|
||||
normalizeLocalReconciliationApplicationIntent,
|
||||
readLocalReconciliationApplicationIntent,
|
||||
type LocalReconciliationApplicationDependencies,
|
||||
type LocalReconciliationApplicationIntent,
|
||||
} from './reconciliation/application/coordinator';
|
||||
export {
|
||||
normalizeLocalReconciliationApplicationPlan,
|
||||
normalizeLocalReconciliationApplicationPlanReceipt,
|
||||
type LocalReconciliationApplicationDatabaseDecisionSummary,
|
||||
type LocalReconciliationApplicationDomainAction,
|
||||
type LocalReconciliationApplicationDomainSummary,
|
||||
type LocalReconciliationApplicationPlan,
|
||||
type LocalReconciliationApplicationPlanReceipt,
|
||||
} from './reconciliation/application/plan';
|
||||
|
||||
export {
|
||||
LocalDeploymentConfigurationError,
|
||||
normalizeLocalDeploymentComposeApplyCommand,
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
verifyLocalReconciliationCaptureCommandFile,
|
||||
verifyLocalReconciliationPlanCommandFile,
|
||||
verifyLocalReconciliationReviewCommandFile,
|
||||
prepareLocalReconciliationApplicationCommandFile,
|
||||
commitLocalReconciliationApplicationCommandFile,
|
||||
verifyLocalReconciliationApplicationCommandFile,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
prepareLocalDeploymentCommandFile,
|
||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||
@@ -37,7 +40,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
@@ -76,6 +79,9 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
argv[0] !== 'reconciliation-review-diagnostics' &&
|
||||
argv[0] !== 'reconciliation-review-commit' &&
|
||||
argv[0] !== 'reconciliation-review-verify' &&
|
||||
argv[0] !== 'reconciliation-application-prepare' &&
|
||||
argv[0] !== 'reconciliation-application-commit' &&
|
||||
argv[0] !== 'reconciliation-application-verify' &&
|
||||
argv[0] !== 'compose-revision' &&
|
||||
argv[0] !== 'compose-preflight' &&
|
||||
argv[0] !== 'compose-apply' &&
|
||||
@@ -166,6 +172,12 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? commitLocalReconciliationReviewCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-review-verify'
|
||||
? verifyLocalReconciliationReviewCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-application-prepare'
|
||||
? prepareLocalReconciliationApplicationCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-application-commit'
|
||||
? commitLocalReconciliationApplicationCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-application-verify'
|
||||
? verifyLocalReconciliationApplicationCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-revision'
|
||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-preflight'
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { currentIdentity } from '../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface LocalReconciliationApplicationOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly captureRoot: string;
|
||||
readonly planRoot: string;
|
||||
readonly reviewRoot: string;
|
||||
readonly applicationRoot: string;
|
||||
readonly issuerKeyringPath: string;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationPrepareCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.application.prepare';
|
||||
readonly options: Readonly<LocalReconciliationApplicationOptions>;
|
||||
readonly request: Readonly<{
|
||||
applicationId: string;
|
||||
reviewId: string;
|
||||
expectedReviewDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
preparedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationCommitCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.application.commit';
|
||||
readonly options: Readonly<LocalReconciliationApplicationOptions>;
|
||||
readonly request: Readonly<{
|
||||
applicationId: string;
|
||||
expectedPreparationDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
committedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationVerifyCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.application.verify';
|
||||
readonly options: Readonly<LocalReconciliationApplicationOptions>;
|
||||
readonly request: Readonly<{
|
||||
applicationId: string;
|
||||
expectedApplicationPlanDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationPrepareResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.application.prepare';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'reconciliation_application_prepared';
|
||||
readonly applicationId: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationTerminalResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
| 'local.deployment.reconciliation.application.commit'
|
||||
| 'local.deployment.reconciliation.application.verify';
|
||||
readonly status: 'prepared' | 'existing' | 'verified';
|
||||
readonly state: 'reconciliation_application_planned';
|
||||
readonly applicationId: string;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly outcome:
|
||||
| 'no_effect_ready'
|
||||
| 'adapter_required'
|
||||
| 'manual_required'
|
||||
| 'adapter_and_manual_required';
|
||||
readonly domainCount: 8;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
configurationError(`${label} must be a safe non-root absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function overlaps(left: string, right: string): boolean {
|
||||
const relative = path.relative(left, right);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
configurationError(`${label} must be a descendant of deploymentRoot`);
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
configurationError(`${label} must be a SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
|
||||
configurationError(`${label} must be a UUID v4`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationOptions> {
|
||||
const options = object(value, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'captureRoot',
|
||||
'deploymentRoot',
|
||||
'issuerKeyringPath',
|
||||
'planRoot',
|
||||
'reviewRoot',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const identity = currentIdentity();
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
configurationError(
|
||||
'reconciliation application command identity is invalid',
|
||||
);
|
||||
}
|
||||
const roots = [
|
||||
safePath(options.deploymentRoot, 'deploymentRoot'),
|
||||
safePath(options.captureRoot, 'captureRoot'),
|
||||
safePath(options.planRoot, 'planRoot'),
|
||||
safePath(options.reviewRoot, 'reviewRoot'),
|
||||
safePath(options.applicationRoot, 'applicationRoot'),
|
||||
];
|
||||
for (let left = 0; left < roots.length; left += 1) {
|
||||
for (let right = left + 1; right < roots.length; right += 1) {
|
||||
if (
|
||||
overlaps(roots[left]!, roots[right]!) ||
|
||||
overlaps(roots[right]!, roots[left]!)
|
||||
) {
|
||||
configurationError(
|
||||
'deployment, capture, plan, review and application roots must not overlap',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const issuerKeyringPath = safePath(
|
||||
options.issuerKeyringPath,
|
||||
'issuerKeyringPath',
|
||||
);
|
||||
descendant(roots[0]!, issuerKeyringPath, 'issuerKeyringPath');
|
||||
return Object.freeze({
|
||||
deploymentRoot: roots[0]!,
|
||||
captureRoot: roots[1]!,
|
||||
planRoot: roots[2]!,
|
||||
reviewRoot: roots[3]!,
|
||||
applicationRoot: roots[4]!,
|
||||
issuerKeyringPath,
|
||||
allowRootService: options.allowRootService,
|
||||
});
|
||||
}
|
||||
|
||||
function command(
|
||||
value: unknown,
|
||||
operation:
|
||||
| LocalReconciliationApplicationPrepareCommand['operation']
|
||||
| LocalReconciliationApplicationCommitCommand['operation']
|
||||
| LocalReconciliationApplicationVerifyCommand['operation'],
|
||||
): Readonly<{
|
||||
options: Readonly<LocalReconciliationApplicationOptions>;
|
||||
request: Record<string, unknown>;
|
||||
}> {
|
||||
const selected = object(value, 'reconciliation application command');
|
||||
exact(
|
||||
selected,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||
configurationError('reconciliation application command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
options: normalizeOptions(selected.options),
|
||||
request: object(selected.request, 'request'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationPrepareCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationPrepareCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.application.prepare',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
'expectedHeadDigest',
|
||||
'expectedReviewDigest',
|
||||
'preparedAtMs',
|
||||
'reviewId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.preparedAtMs) ||
|
||||
(selected.request.preparedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('reconciliation application prepare command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.prepare',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
'applicationId',
|
||||
),
|
||||
reviewId: identifier(selected.request.reviewId, 'reviewId'),
|
||||
expectedReviewDigest: digest(
|
||||
selected.request.expectedReviewDigest,
|
||||
'expectedReviewDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
preparedAtMs: selected.request.preparedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationCommitCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationCommitCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.application.commit',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
'committedAtMs',
|
||||
'expectedHeadDigest',
|
||||
'expectedPreparationDigest',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.committedAtMs) ||
|
||||
(selected.request.committedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('reconciliation application commit command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.commit',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
'applicationId',
|
||||
),
|
||||
expectedPreparationDigest: digest(
|
||||
selected.request.expectedPreparationDigest,
|
||||
'expectedPreparationDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
committedAtMs: selected.request.committedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationVerifyCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationVerifyCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.application.verify',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
['applicationId', 'expectedApplicationPlanDigest'],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.verify',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
'applicationId',
|
||||
),
|
||||
expectedApplicationPlanDigest: digest(
|
||||
selected.request.expectedApplicationPlanDigest,
|
||||
'expectedApplicationPlanDigest',
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
+1010
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,510 @@
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import { cutoverDigest } from '../../cutover/targetEvidence';
|
||||
import type {
|
||||
LocalReconciliationReviewAuthorizationDomainDecisionCounts,
|
||||
LocalReconciliationReviewAuthorizationEvidence,
|
||||
} from '../review/authorization';
|
||||
import {
|
||||
LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS,
|
||||
type LocalReconciliationReviewDisposition,
|
||||
} from '../review/decisionFile';
|
||||
import {
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||
type LocalReconciliationPlanDomain,
|
||||
} from '../planning/contract';
|
||||
import type { LocalReconciliationApplicationIntent } from './coordinator';
|
||||
|
||||
const PLAN_SCHEMA = 'qinglong3-local-reconciliation-application-plan';
|
||||
const RECEIPT_SCHEMA =
|
||||
'qinglong3-local-reconciliation-application-plan-receipt';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const MAX_PLAN_BYTES = 64 * 1024;
|
||||
|
||||
export type LocalReconciliationApplicationDomainAction =
|
||||
| 'no_effect'
|
||||
| 'adapter_required'
|
||||
| 'manual_external'
|
||||
| 'adapter_and_manual';
|
||||
|
||||
export interface LocalReconciliationApplicationDatabaseDecisionSummary {
|
||||
readonly decisionCount: number;
|
||||
readonly dispositionCounts: Readonly<
|
||||
Record<LocalReconciliationReviewDisposition, number>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationDomainSummary {
|
||||
readonly domain: LocalReconciliationPlanDomain;
|
||||
readonly legacy: Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
|
||||
readonly target: Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
|
||||
readonly action: LocalReconciliationApplicationDomainAction;
|
||||
readonly summaryDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationPlan {
|
||||
readonly schema: typeof PLAN_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_application_planned';
|
||||
readonly applicationId: string;
|
||||
readonly reviewId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly preparationDigest: string;
|
||||
readonly preparedHeadDigest: string;
|
||||
readonly reviewDigest: string;
|
||||
readonly authorizationDigest: string;
|
||||
readonly decisionSetDigest: string;
|
||||
readonly decisionCount: number;
|
||||
readonly committedAtMs: number;
|
||||
readonly domains: readonly Readonly<LocalReconciliationApplicationDomainSummary>[];
|
||||
readonly outcome:
|
||||
| 'no_effect_ready'
|
||||
| 'adapter_required'
|
||||
| 'manual_required'
|
||||
| 'adapter_and_manual_required';
|
||||
readonly applicationPlanDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationPlanReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_application_planned';
|
||||
readonly applicationId: string;
|
||||
readonly reviewId: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly preparedHeadDigest: string;
|
||||
readonly reviewDigest: string;
|
||||
readonly authorizationDigest: string;
|
||||
readonly decisionSetDigest: string;
|
||||
readonly decisionCount: number;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly outcome: LocalReconciliationApplicationPlan['outcome'];
|
||||
readonly domainCount: 8;
|
||||
readonly committedAtMs: number;
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function counts(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<LocalReconciliationReviewDisposition, number>> {
|
||||
const selected = object(value, label);
|
||||
exact(selected, LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS, label);
|
||||
if (
|
||||
LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS.some(
|
||||
(disposition) => !safeCount(selected[disposition]),
|
||||
)
|
||||
) {
|
||||
configurationError(`${label} is invalid`);
|
||||
}
|
||||
return Object.freeze(selected) as Readonly<
|
||||
Record<LocalReconciliationReviewDisposition, number>
|
||||
>;
|
||||
}
|
||||
|
||||
function sumCounts(
|
||||
selected: Readonly<Record<LocalReconciliationReviewDisposition, number>>,
|
||||
): number {
|
||||
const total = LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS.reduce(
|
||||
(sum, disposition) => sum + BigInt(selected[disposition]),
|
||||
0n,
|
||||
);
|
||||
if (total > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
configurationError('application decision count overflowed');
|
||||
}
|
||||
return Number(total);
|
||||
}
|
||||
|
||||
function databaseSummary(
|
||||
selected: Readonly<Record<LocalReconciliationReviewDisposition, number>>,
|
||||
): Readonly<LocalReconciliationApplicationDatabaseDecisionSummary> {
|
||||
const dispositionCounts = Object.freeze({ ...selected });
|
||||
return Object.freeze({
|
||||
decisionCount: sumCounts(dispositionCounts),
|
||||
dispositionCounts,
|
||||
});
|
||||
}
|
||||
|
||||
function action(
|
||||
selected: Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>,
|
||||
): LocalReconciliationApplicationDomainAction {
|
||||
const adapter =
|
||||
selected.legacy.adopt_legacy +
|
||||
selected.legacy.retain_both +
|
||||
selected.target.retain_both >
|
||||
0;
|
||||
const manual =
|
||||
selected.legacy.defer +
|
||||
selected.legacy.manual_external +
|
||||
selected.target.defer +
|
||||
selected.target.manual_external >
|
||||
0;
|
||||
if (adapter && manual) return 'adapter_and_manual';
|
||||
if (adapter) return 'adapter_required';
|
||||
if (manual) return 'manual_external';
|
||||
return 'no_effect';
|
||||
}
|
||||
|
||||
function outcome(
|
||||
domains: readonly Readonly<LocalReconciliationApplicationDomainSummary>[],
|
||||
): LocalReconciliationApplicationPlan['outcome'] {
|
||||
const adapter = domains.some(
|
||||
(domain) =>
|
||||
domain.action === 'adapter_required' ||
|
||||
domain.action === 'adapter_and_manual',
|
||||
);
|
||||
const manual = domains.some(
|
||||
(domain) =>
|
||||
domain.action === 'manual_external' ||
|
||||
domain.action === 'adapter_and_manual',
|
||||
);
|
||||
if (adapter && manual) return 'adapter_and_manual_required';
|
||||
if (adapter) return 'adapter_required';
|
||||
if (manual) return 'manual_required';
|
||||
return 'no_effect_ready';
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationApplicationPlan(
|
||||
intent: Readonly<LocalReconciliationApplicationIntent>,
|
||||
authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>,
|
||||
committedAtMs: number,
|
||||
preparedHeadDigest: string,
|
||||
): Readonly<LocalReconciliationApplicationPlan> {
|
||||
if (
|
||||
authorization.domainDecisionCounts.length !== 8 ||
|
||||
authorization.decisionCount !==
|
||||
authorization.domainDecisionCounts.reduce(
|
||||
(total, selected) =>
|
||||
total + sumCounts(selected.legacy) + sumCounts(selected.target),
|
||||
0,
|
||||
)
|
||||
) {
|
||||
configurationError('authorization domain decision summary drifted');
|
||||
}
|
||||
const domains = Object.freeze(
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) => {
|
||||
const selected = authorization.domainDecisionCounts[index];
|
||||
if (!selected || selected.domain !== domain) {
|
||||
configurationError('authorization domain ordering drifted');
|
||||
}
|
||||
const legacy = databaseSummary(selected.legacy);
|
||||
const target = databaseSummary(selected.target);
|
||||
const selectedAction = action(selected);
|
||||
const summaryPayload = Object.freeze({
|
||||
domain,
|
||||
legacy,
|
||||
target,
|
||||
action: selectedAction,
|
||||
});
|
||||
return Object.freeze({
|
||||
...summaryPayload,
|
||||
summaryDigest: cutoverDigest(summaryPayload),
|
||||
});
|
||||
}),
|
||||
);
|
||||
const payload = Object.freeze({
|
||||
schema: PLAN_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_application_planned' as const,
|
||||
applicationId: intent.command.request.applicationId,
|
||||
reviewId: intent.command.request.reviewId,
|
||||
profile: intent.profile,
|
||||
preparationDigest: intent.preparationDigest,
|
||||
preparedHeadDigest,
|
||||
reviewDigest: intent.reviewDigest,
|
||||
authorizationDigest: intent.authorizationDigest,
|
||||
decisionSetDigest: intent.decisionSetDigest,
|
||||
decisionCount: intent.decisionCount,
|
||||
committedAtMs,
|
||||
domains,
|
||||
outcome: outcome(domains),
|
||||
});
|
||||
const plan = Object.freeze({
|
||||
...payload,
|
||||
applicationPlanDigest: cutoverDigest(payload),
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(`${JSON.stringify(plan, null, 2)}\n`, 'utf8') >
|
||||
MAX_PLAN_BYTES
|
||||
) {
|
||||
configurationError('reconciliation application plan exceeds 64 KiB');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function normalizeDatabaseSummary(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<LocalReconciliationApplicationDatabaseDecisionSummary> {
|
||||
const selected = object(value, label);
|
||||
exact(selected, ['decisionCount', 'dispositionCounts'], label);
|
||||
const dispositionCounts = counts(
|
||||
selected.dispositionCounts,
|
||||
`${label} disposition counts`,
|
||||
);
|
||||
if (
|
||||
!safeCount(selected.decisionCount) ||
|
||||
selected.decisionCount !== sumCounts(dispositionCounts)
|
||||
) {
|
||||
configurationError(`${label} aggregate drifted`);
|
||||
}
|
||||
return Object.freeze({
|
||||
decisionCount: selected.decisionCount,
|
||||
dispositionCounts,
|
||||
}) as Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
|
||||
}
|
||||
|
||||
function normalizeDomain(
|
||||
value: unknown,
|
||||
expectedDomain: LocalReconciliationPlanDomain,
|
||||
): Readonly<LocalReconciliationApplicationDomainSummary> {
|
||||
const selected = object(value, 'reconciliation application domain');
|
||||
exact(
|
||||
selected,
|
||||
['action', 'domain', 'legacy', 'summaryDigest', 'target'],
|
||||
'reconciliation application domain',
|
||||
);
|
||||
const legacy = normalizeDatabaseSummary(selected.legacy, 'legacy summary');
|
||||
const target = normalizeDatabaseSummary(selected.target, 'target summary');
|
||||
const summaryPayload = Object.freeze({
|
||||
domain: selected.domain,
|
||||
legacy,
|
||||
target,
|
||||
action: selected.action,
|
||||
});
|
||||
if (
|
||||
selected.domain !== expectedDomain ||
|
||||
![
|
||||
'no_effect',
|
||||
'adapter_required',
|
||||
'manual_external',
|
||||
'adapter_and_manual',
|
||||
].includes(selected.action as string) ||
|
||||
typeof selected.summaryDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(selected.summaryDigest) ||
|
||||
selected.summaryDigest !== cutoverDigest(summaryPayload)
|
||||
) {
|
||||
configurationError('reconciliation application domain drifted');
|
||||
}
|
||||
const derivedAction = action({
|
||||
domain: expectedDomain,
|
||||
legacy: legacy.dispositionCounts,
|
||||
target: target.dispositionCounts,
|
||||
});
|
||||
if (selected.action !== derivedAction) {
|
||||
configurationError('reconciliation application domain action drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
domain: expectedDomain,
|
||||
legacy,
|
||||
target,
|
||||
action: selected.action,
|
||||
summaryDigest: selected.summaryDigest,
|
||||
}) as Readonly<LocalReconciliationApplicationDomainSummary>;
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationPlan(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationPlan> {
|
||||
const plan = object(value, 'reconciliation application plan');
|
||||
exact(
|
||||
plan,
|
||||
[
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'authorizationDigest',
|
||||
'committedAtMs',
|
||||
'decisionCount',
|
||||
'decisionSetDigest',
|
||||
'domains',
|
||||
'outcome',
|
||||
'preparationDigest',
|
||||
'preparedHeadDigest',
|
||||
'profile',
|
||||
'reviewDigest',
|
||||
'reviewId',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'reconciliation application plan',
|
||||
);
|
||||
if (!Array.isArray(plan.domains) || plan.domains.length !== 8) {
|
||||
configurationError('reconciliation application domains are invalid');
|
||||
}
|
||||
const domains = Object.freeze(
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) =>
|
||||
normalizeDomain((plan.domains as unknown[])[index], domain),
|
||||
),
|
||||
);
|
||||
const { applicationPlanDigest, ...rawPayload } = plan;
|
||||
const payload = Object.freeze({ ...rawPayload, domains });
|
||||
const domainDecisionCount = domains.reduce(
|
||||
(total, domain) =>
|
||||
total + domain.legacy.decisionCount + domain.target.decisionCount,
|
||||
0,
|
||||
);
|
||||
if (
|
||||
plan.schema !== PLAN_SCHEMA ||
|
||||
plan.schemaVersion !== 1 ||
|
||||
plan.state !== 'reconciliation_application_planned' ||
|
||||
typeof plan.applicationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(plan.applicationId) ||
|
||||
typeof plan.reviewId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(plan.reviewId) ||
|
||||
(plan.profile !== 'edge' && plan.profile !== 'standalone') ||
|
||||
!safeCount(plan.decisionCount) ||
|
||||
plan.decisionCount !== domainDecisionCount ||
|
||||
!Number.isSafeInteger(plan.committedAtMs) ||
|
||||
(plan.committedAtMs as number) < 0 ||
|
||||
![
|
||||
'no_effect_ready',
|
||||
'adapter_required',
|
||||
'manual_required',
|
||||
'adapter_and_manual_required',
|
||||
].includes(plan.outcome as string) ||
|
||||
plan.outcome !== outcome(domains) ||
|
||||
[
|
||||
plan.preparationDigest,
|
||||
plan.preparedHeadDigest,
|
||||
plan.reviewDigest,
|
||||
plan.authorizationDigest,
|
||||
plan.decisionSetDigest,
|
||||
applicationPlanDigest,
|
||||
].some(
|
||||
(candidate) =>
|
||||
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
|
||||
) ||
|
||||
cutoverDigest(payload) !== applicationPlanDigest
|
||||
) {
|
||||
configurationError('reconciliation application plan drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
...(plan as unknown as LocalReconciliationApplicationPlan),
|
||||
domains,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationApplicationPlanReceipt(
|
||||
plan: Readonly<LocalReconciliationApplicationPlan>,
|
||||
): Readonly<LocalReconciliationApplicationPlanReceipt> {
|
||||
const payload = Object.freeze({
|
||||
schema: RECEIPT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_application_planned' as const,
|
||||
applicationId: plan.applicationId,
|
||||
reviewId: plan.reviewId,
|
||||
preparationDigest: plan.preparationDigest,
|
||||
preparedHeadDigest: plan.preparedHeadDigest,
|
||||
reviewDigest: plan.reviewDigest,
|
||||
authorizationDigest: plan.authorizationDigest,
|
||||
decisionSetDigest: plan.decisionSetDigest,
|
||||
decisionCount: plan.decisionCount,
|
||||
applicationPlanDigest: plan.applicationPlanDigest,
|
||||
outcome: plan.outcome,
|
||||
domainCount: 8 as const,
|
||||
committedAtMs: plan.committedAtMs,
|
||||
});
|
||||
return Object.freeze({ ...payload, receiptDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationPlanReceipt(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationPlanReceipt> {
|
||||
const receipt = object(value, 'reconciliation application receipt');
|
||||
exact(
|
||||
receipt,
|
||||
[
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'authorizationDigest',
|
||||
'committedAtMs',
|
||||
'decisionCount',
|
||||
'decisionSetDigest',
|
||||
'domainCount',
|
||||
'outcome',
|
||||
'preparationDigest',
|
||||
'preparedHeadDigest',
|
||||
'receiptDigest',
|
||||
'reviewDigest',
|
||||
'reviewId',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'reconciliation application receipt',
|
||||
);
|
||||
const { receiptDigest, ...payload } = receipt;
|
||||
if (
|
||||
receipt.schema !== RECEIPT_SCHEMA ||
|
||||
receipt.schemaVersion !== 1 ||
|
||||
receipt.state !== 'reconciliation_application_planned' ||
|
||||
typeof receipt.applicationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(receipt.applicationId) ||
|
||||
typeof receipt.reviewId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(receipt.reviewId) ||
|
||||
!safeCount(receipt.decisionCount) ||
|
||||
receipt.domainCount !== 8 ||
|
||||
!Number.isSafeInteger(receipt.committedAtMs) ||
|
||||
(receipt.committedAtMs as number) < 0 ||
|
||||
![
|
||||
'no_effect_ready',
|
||||
'adapter_required',
|
||||
'manual_required',
|
||||
'adapter_and_manual_required',
|
||||
].includes(receipt.outcome as string) ||
|
||||
[
|
||||
receipt.preparationDigest,
|
||||
receipt.preparedHeadDigest,
|
||||
receipt.reviewDigest,
|
||||
receipt.authorizationDigest,
|
||||
receipt.decisionSetDigest,
|
||||
receipt.applicationPlanDigest,
|
||||
receiptDigest,
|
||||
].some(
|
||||
(candidate) =>
|
||||
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
|
||||
) ||
|
||||
cutoverDigest(payload) !== receiptDigest
|
||||
) {
|
||||
configurationError('reconciliation application receipt drifted');
|
||||
}
|
||||
return receipt as unknown as Readonly<LocalReconciliationApplicationPlanReceipt>;
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import {
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||
type LocalReconciliationPlanDomain,
|
||||
} from '../planning/contract';
|
||||
import type {
|
||||
LocalReconciliationReviewDecision,
|
||||
LocalReconciliationReviewDisposition,
|
||||
@@ -54,9 +58,20 @@ export interface LocalReconciliationReviewAuthorizationEvidence {
|
||||
readonly reasonCounts: Readonly<
|
||||
Record<LocalReconciliationReviewReason, number>
|
||||
>;
|
||||
readonly domainDecisionCounts: readonly Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>[];
|
||||
readonly header: Readonly<LocalReconciliationReviewAuthorizationHeader>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationReviewAuthorizationDomainDecisionCounts {
|
||||
readonly domain: LocalReconciliationPlanDomain;
|
||||
readonly legacy: Readonly<
|
||||
Record<LocalReconciliationReviewDisposition, number>
|
||||
>;
|
||||
readonly target: Readonly<
|
||||
Record<LocalReconciliationReviewDisposition, number>
|
||||
>;
|
||||
}
|
||||
|
||||
interface SignatureRecord {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: typeof SIGNATURE_KIND;
|
||||
@@ -97,6 +112,32 @@ function zeroCounts<T extends string>(keys: readonly T[]): Record<T, number> {
|
||||
return Object.fromEntries(keys.map((key) => [key, 0])) as Record<T, number>;
|
||||
}
|
||||
|
||||
function zeroDomainDecisionCounts(): Array<{
|
||||
domain: LocalReconciliationPlanDomain;
|
||||
legacy: Record<LocalReconciliationReviewDisposition, number>;
|
||||
target: Record<LocalReconciliationReviewDisposition, number>;
|
||||
}> {
|
||||
return LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain) => ({
|
||||
domain,
|
||||
legacy: zeroCounts(DISPOSITIONS),
|
||||
target: zeroCounts(DISPOSITIONS),
|
||||
}));
|
||||
}
|
||||
|
||||
function freezeDomainDecisionCounts(
|
||||
counts: ReturnType<typeof zeroDomainDecisionCounts>,
|
||||
): readonly Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>[] {
|
||||
return Object.freeze(
|
||||
counts.map((selected) =>
|
||||
Object.freeze({
|
||||
domain: selected.domain,
|
||||
legacy: Object.freeze({ ...selected.legacy }),
|
||||
target: Object.freeze({ ...selected.target }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation review authorization ${message}`,
|
||||
@@ -429,6 +470,7 @@ export async function publishLocalReconciliationReviewAuthorization(
|
||||
let decisionCount = 0;
|
||||
const dispositionCounts = zeroCounts(DISPOSITIONS);
|
||||
const reasonCounts = zeroCounts(REASONS);
|
||||
const domainDecisionCounts = zeroDomainDecisionCounts();
|
||||
const writeContent = (value: unknown, isDecision: boolean): void => {
|
||||
const line = canonicalLine(value);
|
||||
try {
|
||||
@@ -452,6 +494,11 @@ export async function publishLocalReconciliationReviewAuthorization(
|
||||
decisionCount += 1;
|
||||
dispositionCounts[selected.disposition] += 1;
|
||||
reasonCounts[selected.reason] += 1;
|
||||
const domain = domainDecisionCounts.find(
|
||||
(candidate) => candidate.domain === selected.domain,
|
||||
);
|
||||
if (!domain) configurationError('decision domain is unavailable');
|
||||
domain[selected.database][selected.disposition] += 1;
|
||||
});
|
||||
if (!DIGEST_PATTERN.test(decisionFile.decisionFileDigest)) {
|
||||
configurationError('decision file evidence is invalid');
|
||||
@@ -532,6 +579,7 @@ export async function publishLocalReconciliationReviewAuthorization(
|
||||
keyId: material.keyId,
|
||||
dispositionCounts: Object.freeze(dispositionCounts),
|
||||
reasonCounts: Object.freeze(reasonCounts),
|
||||
domainDecisionCounts: freezeDomainDecisionCounts(domainDecisionCounts),
|
||||
header: options.header,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -668,6 +716,7 @@ export async function verifyLocalReconciliationReviewAuthorization(
|
||||
}
|
||||
const dispositionCounts = zeroCounts(DISPOSITIONS);
|
||||
const reasonCounts = zeroCounts(REASONS);
|
||||
const domainDecisionCounts = zeroDomainDecisionCounts();
|
||||
let decisionCount = 0;
|
||||
let pending = nextLine();
|
||||
if (pending === null) configurationError('signature is absent');
|
||||
@@ -679,6 +728,11 @@ export async function verifyLocalReconciliationReviewAuthorization(
|
||||
decisionCount += 1;
|
||||
dispositionCounts[selected.disposition] += 1;
|
||||
reasonCounts[selected.reason] += 1;
|
||||
const domain = domainDecisionCounts.find(
|
||||
(candidate) => candidate.domain === selected.domain,
|
||||
);
|
||||
if (!domain) configurationError('decision domain is unavailable');
|
||||
domain[selected.database][selected.disposition] += 1;
|
||||
pending.fill(0);
|
||||
pending = following;
|
||||
}
|
||||
@@ -740,6 +794,7 @@ export async function verifyLocalReconciliationReviewAuthorization(
|
||||
keyId: signature.keyId,
|
||||
dispositionCounts: Object.freeze(dispositionCounts),
|
||||
reasonCounts: Object.freeze(reasonCounts),
|
||||
domainDecisionCounts: freezeDomainDecisionCounts(domainDecisionCounts),
|
||||
header: normalizedHeader,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -504,6 +504,50 @@ async function readTerminal(
|
||||
return Object.freeze({ authorization, review, receipt });
|
||||
}
|
||||
|
||||
export interface LocalReconciliationReviewTerminal {
|
||||
readonly intent: Readonly<LocalReconciliationReviewIntent>;
|
||||
readonly authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>;
|
||||
readonly review: Readonly<LocalReconciliationReview>;
|
||||
readonly receipt: Readonly<LocalReconciliationReviewReceipt>;
|
||||
}
|
||||
|
||||
export async function readLocalReconciliationReviewTerminal(
|
||||
reviewRoot: string,
|
||||
reviewId: string,
|
||||
issuerKeyringPath: string,
|
||||
uid: number,
|
||||
): Promise<Readonly<LocalReconciliationReviewTerminal>> {
|
||||
const selected = paths(reviewRoot, reviewId);
|
||||
validateDirectory(
|
||||
selected.root,
|
||||
uid,
|
||||
[0o500],
|
||||
'reconciliationReviewDirectory',
|
||||
);
|
||||
validateDirectory(
|
||||
selected.staging,
|
||||
uid,
|
||||
[0o500],
|
||||
'reconciliationReviewStaging',
|
||||
);
|
||||
validateCatalog(selected, true);
|
||||
const intent = readIntent(selected, uid);
|
||||
if (
|
||||
intent.command.options.reviewRoot !== reviewRoot ||
|
||||
intent.command.request.reviewId !== reviewId
|
||||
) {
|
||||
configurationError('terminal reconciliation review path binding drifted');
|
||||
}
|
||||
const terminal = await readTerminal(
|
||||
selected,
|
||||
intent,
|
||||
issuerKeyringPath,
|
||||
uid,
|
||||
[0o400],
|
||||
);
|
||||
return Object.freeze({ intent, ...terminal });
|
||||
}
|
||||
|
||||
function result(
|
||||
operation: LocalReconciliationReviewTerminalResult['operation'],
|
||||
status: LocalReconciliationReviewTerminalResult['status'],
|
||||
|
||||
@@ -286,6 +286,10 @@ export function assertLocalReconciliationReviewDecisionMatchesFact(
|
||||
}
|
||||
if (
|
||||
fact.decisionRequirement === 'informational' ||
|
||||
(fact.database === 'legacy' && selected.disposition === 'retain_target') ||
|
||||
(fact.database === 'target' &&
|
||||
(selected.disposition === 'adopt_legacy' ||
|
||||
selected.disposition === 'exclude_legacy')) ||
|
||||
(fact.decisionRequirement === 'blocked' &&
|
||||
selected.disposition !== 'defer' &&
|
||||
selected.disposition !== 'manual_external') ||
|
||||
|
||||
+3
-1
@@ -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 ||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user