From fd10f72aba4d6777f49da256a4b1cd3ebc2e46f0 Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 24 Aug 2026 13:55:54 +0800 Subject: [PATCH] fix(ql3): preserve target-native reconciliation domains --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 15 +- ...-config-reconciliation-and-task-binding.md | 4 +- ...-compatible-secret-config-completion-v3.md | 2 +- ...tive-reconciliation-domain-preservation.md | 73 ++++++ docs/adr/README.md | 1 + .../reconciliation/planning/inventory.ts | 12 + .../reconciliation/planning/plan.ts | 7 +- .../reconciliation/review/decisionFile.ts | 12 +- .../reconciliation/review/diagnostics.ts | 6 + .../reconciliationCapturePrepare.test.cjs | 237 +++++++++--------- 10 files changed, 244 insertions(+), 125 deletions(-) create mode 100644 docs/adr/ADR-0493-target-native-reconciliation-domain-preservation.md diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 4ed6992d..303fe114 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -58,10 +58,19 @@ `Secret/Config backup.sha256 == Automation targetAfter.sha256`,再证明 current target 等于 Secret/Config `targetAfter`。lineage 只新增 `reconciliation_secret_config_applied → reconciliation_completed` 一条合法边;receipt seal 与 completed head durable 后,才幂等删除两类数据库等量 backup。Secret/Config 的 ciphertext material、intent 与 receipt 继续 `0400` 保留,backup/rollback root 封为 `0500`,不增加后台 GC、timer 或常驻内存。 - rolled-back、target drift、提前丢失 backup 与其余 manual 域继续失败关闭;完整迁移库当前仍可能因 Identity/Unknown 保持 manual,这不是 v3 可绕过的限制。 + rolled-back、target drift、提前丢失 backup 与其余 manual 域继续失败关闭。 + 第九切片以 D-398/ADR-0493 修正 planner 对目标原生数据的两个架构误判,而没有新增 adapter 或放宽未知 schema。fresh v52 中 + `QingLong3LegacyAdoptions`、`QingLong3LegacyAdoptionTasks`、`QingLong3LocalExecutionContextRecipes`、`QingLong3LocalTaskExecutionRevisions` + 精确归入 Automation,`QingLong3RunAttemptLogArtifactTombstones`、`QingLong3RunAttemptLogRetentionState` 精确归入 Run History;不使用 + `QingLong3*` 宽泛通配,未来未登记表仍是 row-free、blocked 的 `unknown`。`identity_policy_audit` 按来源分离:没有 Legacy `Auths/Users` + 时,Target 原生 Project/RoleBinding/Identity/Credential/Pepper/Approval/SecurityAudit/LocalOwner 事实为 `target_only`,必须逐事实 signed + `retain_target`,随后才以 `no_effect/application_summary` 进入 completion;只要存在 Legacy 身份事实,仍为 + `manual_required/identity_custody_required`,本切片不声称旧 credential、session、token、Policy 或 Audit 已迁移。Secret/Config v52 fixture + 现已用真实 `complete → replay → verify` 完成 v3 闭环,不再由测试伪造 receipt、直接推进 head 或手工回收 storage;真正未知表和 Legacy 身份回归 + 继续失败关闭。 全部 evidence 不含原 Env name/value、目标 ciphertext/key ID 或 row body。v52 Local SQLite 完整测试为 `247/247`,publisher 定向回归 `6/6`;fresh Edge - readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Admin 为 `96/96`,Local Owner 为 - `300 total / 293 pass / 7 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试单次退出 0,完整 backend 为 + readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Admin 为 `96/96`,ADR-0493 后 Local Owner 有效结果为 + `301 total / 294 pass / 7 conditional skip / 0 fail`;完整 backend 为 `1567 total / 1565 pass / 2 conditional skip / 0 fail`。package boundary、精确 Cluster dependency/legacy boundary、122-module Edge import、本地镜像与 `14/14` Local artifact audit 全部 compatible;基础 Edge/Standalone 为 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,距 4 MiB 上限仍分别保留 `1,558,775 / 1,558,697 bytes`,且闭包只有 Local SQLite、runtime-core 与 SemVer,没有 Cluster/PostgreSQL 依赖。 diff --git a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md index ef0c6edd..f4a86fb3 100644 --- a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md +++ b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md @@ -143,6 +143,6 @@ Cluster 不得把 Legacy Env 明文写入 PostgreSQL、ConfigMap、Job command D-397 当前八切片已经实现:absent、unsupported、Edge over-budget、2.x 顺序、同名连接、disabled preservation、保留前缀、异常状态、effective overflow、candidate digest、content-free diagnostics、私有有界 row plan、目标 Secret 冲突、Automation adoption projection、no-effect/manual outcome、durable no-replace publication、terminal seal、head CAS、逐候选独立 signed decision、同一强认证 reviewer、decision/authorization byte bound、`skip → manual_required`、prepare/commit response-loss exact replay、只读 terminal verify、v51 逐 Task/Trigger adoption provenance、v52 Local SQLite 原子 application publisher、Owner prepared/apply/rollback orchestration,以及 ADR-0492 completion v3。v52 在一个 `BEGIN IMMEDIATE` 内复验 Project/RoleBinding、外部 authority、逐 Task/Trigger provenance、当前 head、Plugin ownership 与 Trigger 数量,流式写入加密 Secret、content-free audit、Task rev2、dispatch、Trigger rev2、schedule 和四类 application ledger;deferred parent FK 允许最多 100,000 Task/500,000 Trigger 逐项发布而不在 JS 堆保留全集。Owner 在写前固定 backup 与 stopped proof,以有界 ciphertext-only material 连接 reviewed decision 和 publisher,并覆盖 apply/rollback 全部 response-loss 窗口。completion v3 保留 v1/v2 exact shape,验证 signed decision/apply/current target/head,只有 completed head durable 后才幂等回收 Secret/Config backup;rolled-back、target drift 与其余 manual 域继续失败关闭。commit response-loss 通过 durable receipt exact replay,并重新验证 Secret envelope、Task/Trigger head 与 schedule;目标占用、provenance 缺项、提交前 authority 漂移均回滚全部 DML,rollback 则恢复写前 snapshot。 -本切片当前验证:Local SQLite `247/247`,其中 Secret/Config application publisher 定向回归 `6/6`;fresh Edge readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Admin 为 `96/96`,Local Owner 为 `300 total / 293 pass / 7 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试单次退出 0,完整 backend 为 `1567 total / 1565 pass / 2 conditional skip / 0 fail`。package boundary、精确 Cluster dependency/legacy boundary、122-module Edge import、service-manager bridge import、本地镜像与 `14/14` Local artifact audit 全部 compatible;Local Admin 为 49 source / 48 nested / 1 root export,Local Owner 为 188/187/1,workspace 仍为 18 packages 且没有单文件或浅层 package。基础 Edge/Standalone 为 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,且没有 Cluster/PostgreSQL 闭包。本切片不改 PostgreSQL schema、连接、role、Pool、容器或 Kubernetes 拓扑,因此不重跑且不重新占有 PostgreSQL HA 证明。 +本切片当前验证:Local SQLite `247/247`,其中 Secret/Config application publisher 定向回归 `6/6`;fresh Edge readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Admin 为 `96/96`;ADR-0493 后 Local Owner 有效结果为 `301 total / 294 pass / 7 conditional skip / 0 fail`。完整 backend 为 `1567 total / 1565 pass / 2 conditional skip / 0 fail`。package boundary、精确 Cluster dependency/legacy boundary、122-module Edge import、service-manager bridge import、本地镜像与 `14/14` Local artifact audit 全部 compatible;Local Admin 为 49 source / 48 nested / 1 root export,Local Owner 为 188/187/1,workspace 仍为 18 packages 且没有单文件或浅层 package。基础 Edge/Standalone 为 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,且没有 Cluster/PostgreSQL 闭包。本切片不改 PostgreSQL schema、连接、role、Pool、容器或 Kubernetes 拓扑,因此不重跑且不重新占有 PostgreSQL HA 证明。 -转为 Accepted 前仍必须完成:真实 Edge 空间预算证据与 Cluster Secret provider live gate;ADR-0492 已完成本机 completion schema 演进和 completed-head 后 rollback material 回收,但本切片的 Local Owner 编排或 PostgreSQL HA 证据不得冒充外部密钥托管,也不得把仍含 Identity/Unknown manual 域的迁移冒充跨领域 completion 已完成。 +转为 Accepted 前仍必须完成:真实 Edge 空间预算证据与 Cluster Secret provider live gate;ADR-0492 已完成本机 completion schema 演进和 completed-head 后 rollback material 回收,ADR-0493 又让没有 Legacy 身份输入的 fresh v52 目标身份经 signed `retain_target` 正确形成 no-effect,并精确消除六张已知目标表的 `unknown` 误判。Legacy `Auths/Users` 或真正未知表仍保持 manual;本切片的 Local Owner 编排或 PostgreSQL HA 证据不得冒充外部密钥托管。 diff --git a/docs/adr/ADR-0492-compatible-secret-config-completion-v3.md b/docs/adr/ADR-0492-compatible-secret-config-completion-v3.md index de91ed01..ec51692f 100644 --- a/docs/adr/ADR-0492-compatible-secret-config-completion-v3.md +++ b/docs/adr/ADR-0492-compatible-secret-config-completion-v3.md @@ -56,7 +56,7 @@ Cluster 不复用本机 receipt、POSIX storage 或 instance head。Cluster comp - completion receipt 的消费者必须按 `schemaVersion` 解析,未知版本继续失败关闭。 - `advanceLocalCutoverInstanceHead` 是 CRITICAL 共享状态机;本 ADR 只增加一条 source-state 边,不改任何旧 transition。 -- 完整迁移库当前仍可能因 `identity_policy_audit` 或 `unknown` 保持 manual;这是正确的全局围栏,不应为了演示 v3 成功而放宽。 +- 本 ADR 验收时完整迁移库仍会因目标原生 `identity_policy_audit` 和六张未登记 v52 表落入 `unknown` 而保持 manual;ADR-0493 已通过精确分类与 signed `retain_target` 修正这两个本地 planner 缺口。Legacy `Auths/Users` 和真正未知表仍保持全局围栏。 - v3 verify 要求 completed storage,不能在 head durable 之前充当修复命令。 ## 被拒绝的替代方案 diff --git a/docs/adr/ADR-0493-target-native-reconciliation-domain-preservation.md b/docs/adr/ADR-0493-target-native-reconciliation-domain-preservation.md new file mode 100644 index 00000000..3f778751 --- /dev/null +++ b/docs/adr/ADR-0493-target-native-reconciliation-domain-preservation.md @@ -0,0 +1,73 @@ +# ADR-0493:目标原生 Reconciliation 域分类与身份保留 + +- 状态:Accepted +- 日期:2026-08-24 +- 决策:D-398 +- 关联:ADR-0483、ADR-0484、ADR-0485、ADR-0488、ADR-0492 + +## 背景 + +Reconciliation 必须同时处理两类不同事实:从 2.x 迁入的数据,以及已经属于 QingLong 3.0 目标库的原生数据。此前实现把 `identity_policy_audit` 整域无条件标为 `manual_required/identity_custody_required`,因此即使 Legacy 完全没有 `Auths/Users`,fresh v52 目标库自带的 Project、RoleBinding、Credential、Pepper、SecurityAudit 与 LocalOwner 表也会阻止全局 completion。 + +同一盘点还发现 fresh v52 的六张已知表没有进入稳定领域,被误归为 `unknown`: + +- Automation:`QingLong3LegacyAdoptions`、`QingLong3LegacyAdoptionTasks`、`QingLong3LocalExecutionContextRecipes`、`QingLong3LocalTaskExecutionRevisions`; +- Run History:`QingLong3RunAttemptLogArtifactTombstones`、`QingLong3RunAttemptLogRetentionState`。 + +这不是缺少新 adapter,而是现有 planner 对目标原生 schema 的知识不完整。为绕过门禁而伪造 completion receipt 或把整个 `QingLong3*` 前缀视为可信都会破坏 fail-closed 边界。 + +## 决策 + +### 1. 已知目标表使用精确领域目录 + +上述六张表按精确表名分别归入 Automation 与 Run History。Schema object 继续按其 `tableName` 继承同一领域,因此相关 index/trigger 不需要单独的宽泛名称规则。 + +不引入 `QingLong3*`、`LegacyAdoption*` 或 `Local*` 的整体通配。未来新增但尚未登记的目标表仍进入 `unknown`,不读取行数,并要求人工外部处理。 + +### 2. 目标原生身份是保留事实,不是迁移事实 + +`identity_policy_audit` 的 disposition 改为按来源判定: + +- Legacy 存在 `Auths/Users` 时继续 `manual_required`; +- Legacy 没有身份事实、Target 有已知身份事实时为 `target_only`; +- 两端都没有时为 `aligned`。 + +Target 身份诊断是 `required/reviewable_fact`,必须在 signed review 中逐事实选择 `retain_target` 才能形成 `no_effect` application action。它不能选择 `adopt_legacy` 或 `exclude_legacy`。Legacy 身份诊断仍是 `blocked/identity_custody_required`,只允许 `defer|manual_external`,本 ADR 不声明 2.x credential、session、password hash、token、Policy 或 Audit 已迁移。 + +### 3. Completion 只消费既有闭合证据 + +本 ADR 不增加 completion schema、adapter、instance state 或绕过规则。目标原生身份经过 canonical diagnostics、signed decision 和 application summary 后,以既有 `no_effect/application_summary` 进入 completion;真正未知表和任何 Legacy 身份事实仍阻止 completion。 + +完整 v52 + Legacy Secret/Config fixture 必须通过真实 v3 completion,而不是测试直接构造 receipt、推进 head 或调用 storage collector。只有 completed head durable 后,Secret/Config backup 才能由既有流程回收。 + +### 4. 部署与资源边界不变 + +实现只修改既有 Local Owner 一次性 reconciliation planner/reviewer,没有新增 workspace package、production dependency、SQL migration、daemon、listener、timer、Pool、容器或 Kubernetes workload。Edge/Standalone 常驻闭包、SQLite cache 上限和 artifact 预算不变;Cluster 仍使用自己的 PostgreSQL、Secret provider 与 HA authority。 + +## 被拒绝的替代方案 + +### 为目标原生身份增加 migration adapter + +拒绝。没有 Legacy 身份输入时不存在要迁移的数据;额外 adapter 只会复制已经由目标数据库拥有的事实并扩大权限面。 + +### 信任全部 QingLong3 前缀 + +拒绝。名称前缀不是 schema ownership 证明,会让未来未知表自动越过人工审查。 + +### 自动跳过目标身份评审 + +拒绝。`target_only` 仍必须形成逐事实 signed `retain_target` 决策,不能仅凭分类器直接授权 completion。 + +### 放行 Legacy Auths/Users + +拒绝。旧身份材料的 custody、hash/token 兼容、撤销、Policy 与 Audit 语义尚未建立独立 adapter;本 ADR 只消除目标原生数据被误当成迁移输入的问题。 + +## 验证 + +- 定向回归 `4/4`:fresh v52 `unknown=0`、目标身份 `retain_target`、Legacy 身份继续 blocked、真正未知表继续 row-free/manual; +- Secret/Config completion v3 使用真实 `complete → replay → verify` 链路,首调 `completed`、重放 `existing`,receipt 为 v3 且 Identity/Unknown 均为 `no_effect`; +- completed head durable 后 Secret/Config backup 被回收,加密 material 与 receipt 保持只读; +- Local Owner 受限沙箱为 `301 total / 291 pass / 7 conditional skip / 3 loopback EPERM`,两个 loopback 文件在沙箱外 `15/15`,有效结果 `301/294/7/0`; +- 完整 backend 为 `1567 total / 1565 pass / 2 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试除 Worker 三条 sandbox loopback 外全部通过,对应 Worker 文件在沙箱外 `8/8`; +- package boundary、精确 Cluster dependency、122-module Edge import、service-manager bridge import、本地镜像与 `14/14` Local artifact audit 全部 compatible;workspace 保持 18 packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`; +- 基础 Edge/Standalone 保持 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,没有 Cluster/PostgreSQL 闭包。 diff --git a/docs/adr/README.md b/docs/adr/README.md index fdc889d8..dbf0fff9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -496,6 +496,7 @@ | [ADR-0490](./ADR-0490-run-history-terminal-preservation.md) | Run History 终态保全与跨领域完成证明 | Accepted | | [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 local apply/rollback/completion complete;Edge/Cluster gate pending) | | [ADR-0492](./ADR-0492-compatible-secret-config-completion-v3.md) | 兼容 Secret/Config 的 Reconciliation Completion v3 | Accepted | +| [ADR-0493](./ADR-0493-target-native-reconciliation-domain-preservation.md) | 目标原生 Reconciliation 域分类与身份保留 | Accepted | ## 规则 diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/inventory.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/inventory.ts index 8f0e33b1..4c00958d 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/inventory.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/inventory.ts @@ -16,6 +16,16 @@ import { const MAX_SCHEMA_OBJECTS = 4_096; const MAX_TABLES = 512; +const TARGET_AUTOMATION_TABLES = new Set([ + 'QingLong3LegacyAdoptions', + 'QingLong3LegacyAdoptionTasks', + 'QingLong3LocalExecutionContextRecipes', + 'QingLong3LocalTaskExecutionRevisions', +]); +const TARGET_RUN_HISTORY_TABLES = new Set([ + 'QingLong3RunAttemptLogArtifactTombstones', + 'QingLong3RunAttemptLogRetentionState', +]); export interface LocalReconciliationDomainInventory { readonly domain: LocalReconciliationPlanDomain; @@ -85,6 +95,7 @@ function targetDomain(name: string): LocalReconciliationPlanDomain { return 'schema_lineage'; } if ( + TARGET_AUTOMATION_TABLES.has(name) || name.includes('TaskDefinition') || name.includes('Trigger') || name.includes('Automation') @@ -95,6 +106,7 @@ function targetDomain(name: string): LocalReconciliationPlanDomain { return 'secret_and_config'; } if ( + TARGET_RUN_HISTORY_TABLES.has(name) || name === 'Runs' || name.startsWith('Run') || name.startsWith('StepRun') || diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/plan.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/plan.ts index c1f401e6..0092fad4 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/plan.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/planning/plan.ts @@ -175,10 +175,11 @@ function domainDisposition( ) { return 'unsupported'; } + if (domain === 'identity_policy_audit' && legacyFacts > 0) { + return 'manual_required'; + } if ( - (domain === 'secret_and_config' || - domain === 'run_history' || - domain === 'identity_policy_audit') && + (domain === 'secret_and_config' || domain === 'run_history') && legacyFacts + targetFacts > 0 ) { return 'manual_required'; 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 83a380d4..bc641960 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 @@ -297,10 +297,14 @@ export function assertLocalReconciliationReviewDecisionMatchesFact( !['retain_target', 'retain_both', 'defer', 'manual_external'].includes( selected.disposition, )) || - ((fact.domain === 'secret_and_config' || - fact.domain === 'identity_policy_audit' || - fact.domain === 'unknown') && - !['defer', 'manual_external'].includes(selected.disposition)) + ((fact.domain === 'secret_and_config' || fact.domain === 'unknown') && + !['defer', 'manual_external'].includes(selected.disposition)) || + (fact.domain === 'identity_policy_audit' && + !( + fact.database === 'target' + ? ['retain_target', 'defer', 'manual_external'] + : ['defer', 'manual_external'] + ).includes(selected.disposition)) ) { configurationError( 'decision disposition is not allowed for canonical fact', diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts index 81dfc7cc..d42b40c3 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts @@ -195,6 +195,12 @@ function requirement( }); } if (domain === 'identity_policy_audit') { + if (database === 'target') { + return Object.freeze({ + decisionRequirement: 'required' as const, + reason: 'reviewable_fact' as const, + }); + } return Object.freeze({ decisionRequirement: 'blocked' as const, reason: 'identity_custody_required' as const, diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index 021aeed8..2f3492f6 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -74,17 +74,6 @@ const { const { targetStoppedEvidence, } = require('../dist/deployment/cutover/targetStopRecordEvidence.js'); -const { - collectLocalReconciliationSecretConfigCompletedStorage, - localReconciliationSecretConfigApplyPaths, - readLocalReconciliationSecretConfigApplyIntent, - validateLocalReconciliationSecretConfigCompletedStorage, -} = require('../dist/deployment/reconciliation/application/secret-and-config/application/storage.js'); -const { - buildLocalReconciliationCompletionReceipt, - normalizeLocalReconciliationCompletionReceipt, -} = require('../dist/deployment/reconciliation/completion/evidence.js'); - function digest(value) { return crypto .createHash('sha256') @@ -2974,6 +2963,55 @@ test('review diagnostics keep secret and unknown facts blocked and row-free', (t ); }); +test('review diagnostics preserve target-native identity but keep legacy identity fail-closed', (t) => { + const initializeDatabases = (paths) => { + planningDatabaseInitializer()(paths); + const legacy = new DatabaseSync(paths.legacySourcePath); + legacy.exec('CREATE TABLE "Auths" (id INTEGER PRIMARY KEY)'); + legacy.close(); + fs.copyFileSync(paths.legacySourcePath, paths.recoveryPath); + fs.chmodSync(paths.recoveryPath, 0o600); + const target = new DatabaseSync(paths.targetDatabasePath); + target.exec( + 'CREATE TABLE "QingLong3IdentityRecords" (id INTEGER PRIMARY KEY)', + ); + target.close(); + }; + const state = preparedReview(t, { + initializeDatabases, + planId: '00000000-0000-4000-8000-000000000333', + reviewId: '00000000-0000-4000-8000-000000000334', + reviewSuffix: 'identity-custody', + }); + assert.equal(state.planned.outcome, 'manual_required'); + const prepared = prepareLocalReconciliationReview(state.reviewCommand); + const legacyCommand = diagnosticCommand(state, prepared, { + database: 'legacy', + domain: 'identity_policy_audit', + outputName: 'legacy-identity.json', + }); + writeLocalReconciliationReviewDiagnostics(legacyCommand); + const legacy = JSON.parse( + fs.readFileSync(legacyCommand.request.outputPath, 'utf8'), + ); + assert.equal(legacy.records[0].name, 'Auths'); + assert.equal(legacy.records[0].decisionRequirement, 'blocked'); + assert.equal(legacy.records[0].reason, 'identity_custody_required'); + + const targetCommand = diagnosticCommand(state, prepared, { + database: 'target', + domain: 'identity_policy_audit', + outputName: 'target-identity.json', + }); + writeLocalReconciliationReviewDiagnostics(targetCommand); + const target = JSON.parse( + fs.readFileSync(targetCommand.request.outputPath, 'utf8'), + ); + assert.equal(target.records[0].name, 'QingLong3IdentityRecords'); + assert.equal(target.records[0].decisionRequirement, 'required'); + assert.equal(target.records[0].reason, 'reviewable_fact'); +}); + test('review diagnostics page at sixty-four and CLI output stays content-free', (t) => { const initializeDatabases = (paths) => { planningDatabaseInitializer()(paths); @@ -4352,7 +4390,7 @@ test('Secret/Config apply publishes encrypted material atomically and recovers e assert.equal(databaseCloses, authentications); }); -test('completion v3 proves Secret/Config apply but preserves rollback authority while other domains remain manual', async (t) => { +test('completion v3 completes a classified v52 target and collects Secret/Config rollback authority', async (t) => { const state = await appliedSecretConfigFixture(t, { suffix: 'completion-v3', }); @@ -4402,116 +4440,71 @@ test('completion v3 proves Secret/Config apply but preserves rollback authority const backupPath = path.join(backupRoot, 'before.sqlite'); const materialPath = path.join(applyRoot, 'materials.ndjson'); assert.equal(fs.existsSync(backupPath), true); - - await assert.rejects( - completeLocalReconciliation(command), - /identity_policy_audit is not terminally reconciled/, + const plan = JSON.parse( + fs.readFileSync( + path.join( + state.planRoot, + state.prepareCommand.request.planId, + 'plan.json', + ), + 'utf8', + ), ); - assert.equal(fs.existsSync(backupPath), true); + assert.deepEqual( + plan.domains.find((domain) => domain.domain === 'unknown'), + { + domain: 'unknown', + legacySchemaObjects: 0, + targetSchemaObjects: 0, + legacyTables: 0, + targetTables: 0, + legacyRows: 0, + targetRows: 0, + rowCountsComplete: true, + inventoryDigest: plan.domains.find( + (domain) => domain.domain === 'unknown', + ).inventoryDigest, + disposition: 'aligned', + }, + ); + assert.equal( + plan.domains.find((domain) => domain.domain === 'identity_policy_audit') + .disposition, + 'target_only', + ); + const targetIdentityDecisions = state.reviewFile.decisions.filter( + (decision) => + decision.database === 'target' && + decision.domain === 'identity_policy_audit', + ); + assert.ok(targetIdentityDecisions.length > 0); + assert.equal( + targetIdentityDecisions.every( + (decision) => decision.disposition === 'retain_target', + ), + true, + ); + + const completed = await completeLocalReconciliation(command); + assert.equal(completed.status, 'completed'); + assert.equal(completed.state, 'reconciliation_completed'); + assert.equal(completed.adapterCount, 1); + assert.equal((await completeLocalReconciliation(command)).status, 'existing'); assert.equal( readLocalCutoverInstanceHead( state.deploymentRoot, state.captureCommand.request.instanceId, state.uid, ).state, - 'reconciliation_secret_config_applied', - ); - assert.equal( - fs.existsSync(path.join(completionRoot, command.request.completionId)), - false, - ); - - const domainNames = [ - 'schema_lineage', - 'automation', - 'secret_and_config', - 'run_history', - 'plugin_package', - 'ai_and_tool', - 'identity_policy_audit', - 'unknown', - ]; - const domains = domainNames.map((domain) => - domain === 'secret_and_config' - ? { - domain, - action: 'adapter_required', - evidenceKind: 'secret_config_application', - evidenceDigest: state.applied.applyDigest, - } - : { - domain, - action: 'no_effect', - evidenceKind: 'application_summary', - evidenceDigest: 'a'.repeat(64), - }, - ); - const receipt = buildLocalReconciliationCompletionReceipt({ - completionId: command.request.completionId, - applicationId: command.request.applicationId, - profile: state.captureCommand.request.profile, - instanceId: state.captureCommand.request.instanceId, - cutoverId: state.captureCommand.request.cutoverId, - generation: 1, - activationDigest: state.captureCommand.request.expectedActivationDigest, - applicationPlanDigest: state.application.applicationPlanDigest, - sourceHeadDigest: state.applied.instanceHeadDigest, - domains, - adapterCount: 1, - completedAtMs: command.request.completedAtMs, - }); - assert.equal(receipt.schemaVersion, 3); - assert.deepEqual( - normalizeLocalReconciliationCompletionReceipt(receipt), - receipt, - ); - - const currentHead = readLocalCutoverInstanceHead( - state.deploymentRoot, - state.captureCommand.request.instanceId, - state.uid, - ); - const durableHead = advanceLocalCutoverInstanceHead( - { - options: { deploymentRoot: state.deploymentRoot }, - request: { - cutoverId: state.captureCommand.request.cutoverId, - profile: state.captureCommand.request.profile, - instanceId: state.captureCommand.request.instanceId, - expectedActivationDigest: - state.captureCommand.request.expectedActivationDigest, - requestedAtMs: command.request.completedAtMs, - }, - }, - state.uid, 'reconciliation_completed', - currentHead.generation, - receipt.completionDigest, ); - assert.equal(durableHead.state, 'reconciliation_completed'); - const selected = localReconciliationSecretConfigApplyPaths( - state.secretConfigApplyRoot, - state.secretConfigId, - ); - const intent = readLocalReconciliationSecretConfigApplyIntent( - selected, - state.uid, - ); - collectLocalReconciliationSecretConfigCompletedStorage( - selected, - intent, - state.uid, - ); - collectLocalReconciliationSecretConfigCompletedStorage( - selected, - intent, - state.uid, - ); - validateLocalReconciliationSecretConfigCompletedStorage( - selected, - intent, - state.uid, + const receipt = JSON.parse( + fs.readFileSync( + path.join(completionRoot, command.request.completionId, 'receipt.json'), + 'utf8', + ), ); + assert.equal(receipt.schemaVersion, 3); assert.equal(fs.existsSync(backupPath), false); assert.deepEqual(fs.readdirSync(backupRoot), []); assert.deepEqual(fs.readdirSync(path.join(applyRoot, 'rollback-work')), []); @@ -4527,6 +4520,26 @@ test('completion v3 proves Secret/Config apply but preserves rollback authority evidenceDigest: state.applied.applyDigest, }, ); + for (const domainName of ['identity_policy_audit', 'unknown']) { + assert.equal( + receipt.domains.find((domain) => domain.domain === domainName).action, + 'no_effect', + ); + } + const verified = await verifyLocalReconciliationCompletion({ + schemaVersion: 3, + operation: 'local.deployment.reconciliation.complete.verify', + options: command.options, + request: { + completionId: command.request.completionId, + applicationId: command.request.applicationId, + expectedCompletionDigest: completed.completionDigest, + automation: null, + secretConfig, + runHistory: null, + }, + }); + assert.equal(verified.status, 'verified'); }); test('completion v3 rejects rolled-back Secret/Config evidence', async (t) => {