From b714f2bf18c05cbce08c46d04e0d3613d2144c2d Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 21 Aug 2026 15:35:48 +0800 Subject: [PATCH] fix(ql3): close adopted lineage acceptance gates --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 25 ++++++---- ...ommitted-legacy-data-deployment-lineage.md | 37 +++++++++++++- .../test/adoptedDeploymentBundle.test.cjs | 48 +++++++++++++++++++ ...-local-compose-preflight-live-contract.cjs | 6 ++- ...l3-local-compose-rollout-live-contract.cjs | 16 +++++-- ...-release-deployment-readiness-contract.cjs | 7 ++- ...calComposeCatalogReleaseSelection.test.cjs | 18 +++++++ ...eleaseDeploymentReadinessContract.test.cjs | 14 +++++- ...ReleasePublicationClosureContract.test.cjs | 2 +- 9 files changed, 155 insertions(+), 18 deletions(-) diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index e9620b80..17a7ce8c 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,15 +11,22 @@ 最新增量证据(2026-08-21): -- D-388/ADR-0481(契约已冻结,实施中):D-387 的 committed data receipt 只作为启动前置事实,不获得 activation 或 - rollback authority。新增 adopted-only Application v4,精确绑定 `commitPath/expectedCommitDigest/expectedReceiptDigest`, - 并要求 Application 在 signal、SQLite、Secret、Plugin、AI 之前以 no-follow、stable-stat 和 canonical digest 验证。 - 既有 `ql3-local-deploy` 将增加独立 `local.deployment.adopted.prepare|verify`,不复用 fresh setup、不安装或启动服务; - systemd/OpenRC 保持 Owner intent → 最小 root bridge → Owner consumer,root 不解析 receipt,最终 cutover journal 显式携带 - commit/receipt digest。adopted Compose 不再错误复用 fresh `/var/lib/qinglong3` path mapping,而使用宿主与容器路径字节相同的 - identity-preserving deployment/source bind,保证 SQLite activation path digest 在真实容器内仍成立;preflight/apply/restore/ - evidence lineage 必须继承同一 receipt。v2 fresh 与 v3 SQLite-only adoption 保持兼容,不新增 package、dependency 或常驻对象。 - 本条只记录 Proposed contract,不能在实现、故障重放、完整测试、架构与 artifact 门完成前宣称 Accepted。 +- D-388/ADR-0481(已接受):D-387 committed data receipt 只作为启动前置事实,不获得 activation/rollback authority。 + adopted-only Application v4 在 signal、SQLite、Secret、Plugin、AI 前以 no-follow stable descriptor 验证 + `commitPath/expectedCommitDigest/expectedReceiptDigest`;独立 `local.deployment.adopted.prepare|verify` 已覆盖 systemd、OpenRC、 + Compose,既不复用 fresh setup 也不产生 service intent。systemd/OpenRC 继续使用 Owner → 最小 root bridge → Owner consumer, + root 不解析 receipt,Owner journal 显式保留 commit/receipt。adopted Compose 使用 path-byte 相同的 deployment/source 双 bind、 + `restart: no`、read-only rootfs、无网络与 Profile 资源上限;preflight、rollout v3、restore/evidence v2 继承相同 lineage。 + restore 保持 activation target device/inode,ENOSPC/部分写以 exact recovery evidence 重放;bundle 发布覆盖 stage-before-link 与 + link-before-cleanup 两个崩溃窗口,Docker response loss 只 inspect。聚焦 adopted `12/12`、SQLite rollout `8/8`、完整 Local + SQLite `239/239`、Local Owner `222 total / 217 pass / 5 conditional skip / 0 fail`、tracked backend + `1540 total / 1538 pass / 2 conditional skip / 0 fail`;18-package clean build/逐包测试、架构/distribution 门与十四档 artifact + audit 全通过。workspace 仍为 18 packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Local Owner + `135 source / 134 nested / 1 root binary entry`。基础 Edge/Standalone artifact 为 + `2,611,978 / 2,612,056` bytes,Adopted 为 `2,831,713 / 2,831,836` bytes,未新增 production dependency 或常驻对象。 + release live gate 已移除过期 v44/v37 硬编码:live preflight/rollout 读取已构建 v50,最终 readiness 要求有界版本且两 Profile + 完全一致。独立 PostgreSQL 18.6 arm64 HA timeline `1→2`/146 gates 继续通过,但本地 receipt 不获得 Cluster authority。固定物理 + Edge/NAS 的断电、FTL 写放大和加密卷销毁仍按本 ADR“未包含”保留,不被单元故障注入冒充。 - D-387/ADR-0480(已接受):把 D-386 prepared model 推进为受认证、Project-scoped、可审计且可恢复的原子 application。 既有 `ql3-adoption` 增加 exact `local-data-directory.adoption.apply|apply.verify`;Owner credential 通过独立短生命周期 SQLite handle 建立 `local_data_adoption` 强认证并要求目标 Project 的 `secret.manage`,publisher 在 `BEGIN IMMEDIATE` 内再次复验 credential、Project 与 diff --git a/docs/adr/ADR-0481-committed-legacy-data-deployment-lineage.md b/docs/adr/ADR-0481-committed-legacy-data-deployment-lineage.md index 28a57292..bb3086f0 100644 --- a/docs/adr/ADR-0481-committed-legacy-data-deployment-lineage.md +++ b/docs/adr/ADR-0481-committed-legacy-data-deployment-lineage.md @@ -1,6 +1,6 @@ # ADR-0481:Committed Legacy Data Receipt 的本机部署 Lineage -- 状态:Proposed(D-388 契约冻结) +- 状态:Accepted - 日期:2026-08-21 - 关联 RFC:QL-RFC-0001 D-05、D-06、D-17、D-64、D-87、D-184、D-259、D-388 - 关联 ADR:ADR-0194、ADR-0309、ADR-0310、ADR-0313、ADR-0314、ADR-0362、ADR-0476、ADR-0477、ADR-0478、ADR-0479、ADR-0480 @@ -181,6 +181,41 @@ Edge 保持 128 MiB/64 PID 描述符预算,Standalone 保持 256 MiB/256 PID 6. 覆盖成功、exact replay、commit/config/mount 漂移、发布崩溃、manager/Docker 响应丢失、ENOSPC 与低配资源边界。 7. 完整 package/backend、架构、distribution、artifact 门通过;workspace package 数、浅包审计和常驻依赖闭包不退化。 +## 实现与验收证据 + +- Application v4、canonical data application commit codec、adopted bundle、systemd/OpenRC Owner consumer 与 Compose + lineage 均已落地。Compose rollout receipt 为 v3,restore/evidence collection receipt 为 v2,三者携带相同 + `applicationConfigDigest/activationDigest/commitmentDigest/legacyDataApplicationCommitDigest/ + legacyDataApplicationReceiptDigest`,fresh receipt 使用同一 schema 但 adopted 字段为 `null`。 +- adopted bundle 聚焦套件 `12/12` 通过:systemd、OpenRC、Compose prepare/verify exact replay,不产生 service intent; + commit/config/mount 漂移失败关闭;真实 `docker compose config --format json` 证明 identity-preserving 双 bind;Docker + `up` response loss 只 inspect;restore 保持 activation 已绑定的 target device/inode;evidence collection 继承同一 + lineage。新增发布故障矩阵覆盖“stage 已 fsync、target 尚未 link”与“target 已 link、stage 尚未清理”,原命令均以 + no-replace bytes 收敛且不激活服务。 +- SQLite rollout safety 聚焦套件 `8/8`、完整 Local SQLite `239/239` 通过。adopted restore 在同一 inode 内有界改写, + ENOSPC/部分写保留 exact source stage 与 old-snapshot recovery evidence,重放后恢复原字节并清理中间材料;fresh restore + 继续使用原 replace 语义。 +- 完整 Local Owner 为 `222 total / 217 pass / 5 root-service conditional skip / 0 fail`。tracked backend 在新增 + release contract 回归后为 `1540 total / 1538 pass / 2 conditional skip / 0 fail`;loopback/TLS 用例在允许本机 + listener 的环境运行。18 个 QL3 workspace package 均完成 clean build 与逐包测试。 +- package boundary schema v6 保持 `workspacePackageCount=18`、`singleSourcePackages=[]`、 + `shallowSourcePackages=[]`;Local Owner 为 `135 source / 134 nested / 1 root binary entry`。没有新增 workspace + package、production dependency、daemon、timer、watcher、listener 或数据库连接。 +- 十四档 Edge/Standalone artifact audit 全 compatible。基础 Edge/Standalone 为 + `2,611,978 / 2,612,056` bytes、319 files、58 loaded modules;Adopted 为 + `2,831,713 / 2,831,836` bytes、339 files、59 modules;Application 为 + `3,669,436 / 3,669,556` bytes;Application+AI 为 `4,529,710 / 4,529,842` bytes;MCP 为 + `7,337,910 / 7,338,018` bytes,均保留预算 headroom。Edge/Standalone Compose 继续分别固定 + `128 MiB/64 PID` 与 `256 MiB/256 PID`。 +- release live preflight/rollout 不再硬编码过期 SQLite v44/v37,而从已构建 Local SQLite contract 读取 v50;最终 + deployment-readiness 不复制实现版本号,只要求有界正整数并要求 Edge/Standalone 对同一 selection 完全一致。catalog + selection、deployment readiness、publication closure 与 tag finalizer 相关回归 `28/28` 通过,image release、local + image、package boundary、cluster dependency、Edge import、service bridge、deployment lock 与 cluster deployment 审计均 + compatible。 +- PostgreSQL/Cluster 语义未由本 ADR 修改,也不由本地 receipt 重新声明。独立 PostgreSQL 18.6 arm64 HA Docker 门仍以 + timeline `1 → 2`、`146` gates 和无 finding 的 evidence audit 通过,用于证明本切片没有破坏既有集群基线,而不是把 + Local lineage 当作 Cluster authority。 + ## 未包含 - Cluster/PostgreSQL/Kubernetes 的 prepared-model application 与 rollout; diff --git a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs index d9330780..be19e7ce 100644 --- a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs +++ b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs @@ -719,6 +719,54 @@ for (const kind of ['systemd', 'openrc', 'compose']) { }); } +test('converges adopted bundle publication crash windows without activation', (t) => { + const state = fixture(t, 'compose'); + prepareLocalDeploymentAdoptedBundle(state.command); + + const applicationPath = path.join(state.root, 'local-application.json'); + const applicationStagePath = path.join( + state.root, + '.local-application.json.ql3-deploy-stage', + ); + const applicationContents = fs.readFileSync(applicationPath); + fs.unlinkSync(applicationPath); + fs.writeFileSync(applicationStagePath, applicationContents, { + mode: 0o600, + flag: 'wx', + }); + + const recoveredStage = prepareLocalDeploymentAdoptedBundle(state.command); + assert.equal(recoveredStage.status, 'prepared'); + assert.equal(recoveredStage.applicationConfiguration.status, 'prepared'); + assert.equal(fs.existsSync(applicationStagePath), false); + assert.deepEqual(fs.readFileSync(applicationPath), applicationContents); + + const receiptPath = path.join(state.root, 'service', 'adopted-bundle.json'); + const receiptStagePath = path.join( + state.root, + 'service', + '.adopted-bundle.json.ql3-deploy-stage', + ); + fs.linkSync(receiptPath, receiptStagePath); + assert.equal(fs.statSync(receiptPath).nlink, 2); + + const recoveredLink = prepareLocalDeploymentAdoptedBundle(state.command); + assert.equal(recoveredLink.status, 'existing'); + assert.equal(fs.existsSync(receiptStagePath), false); + assert.equal(fs.statSync(receiptPath).nlink, 1); + assert.equal( + verifyLocalDeploymentAdoptedBundle({ + ...state.command, + operation: 'local.deployment.adopted.verify', + }).status, + 'verified', + ); + assert.equal( + fs.existsSync(path.join(state.root, 'service', 'intents')), + false, + ); +}); + test('rejects commit drift before publishing any bundle material', (t) => { const state = fixture(t, 'systemd'); const commit = JSON.parse(fs.readFileSync(state.commitPath, 'utf8')); diff --git a/scripts/ql3-local-compose-preflight-live-contract.cjs b/scripts/ql3-local-compose-preflight-live-contract.cjs index c2ca4694..cc3e286f 100644 --- a/scripts/ql3-local-compose-preflight-live-contract.cjs +++ b/scripts/ql3-local-compose-preflight-live-contract.cjs @@ -70,6 +70,10 @@ async function main() { root, 'packages/ql3-local-owner-cli/dist/deployment/localDeployment.js', )); + const { LOCAL_SQLITE_CONTRACT_VERSION } = require(path.join( + root, + 'packages/ql3-local-sqlite/dist/readiness/readinessInspection.js', + )); const temporaryRoot = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-compose-preflight-')), ); @@ -157,7 +161,7 @@ async function main() { report.status !== 'ready' || report.generation !== 1 || report.profile !== input.profile || - report.sqlite?.contractVersion !== 44 || + report.sqlite?.contractVersion !== LOCAL_SQLITE_CONTRACT_VERSION || (report.image?.architecture !== 'amd64' && report.image?.architecture !== 'arm64') || report.service?.kind !== 'compose' diff --git a/scripts/ql3-local-compose-rollout-live-contract.cjs b/scripts/ql3-local-compose-rollout-live-contract.cjs index 679cb5d0..ad485214 100644 --- a/scripts/ql3-local-compose-rollout-live-contract.cjs +++ b/scripts/ql3-local-compose-rollout-live-contract.cjs @@ -289,10 +289,17 @@ async function main() { root, 'packages/ql3-local-owner-cli/dist/deployment/localDeployment.js', )); - const { inspectLocalSqliteRolloutBackup } = require(path.join( + const { + inspectLocalSqliteRolloutBackup, + LOCAL_SQLITE_WRITE_CONTRACT_VERSION, + } = require(path.join( root, 'packages/ql3-local-sqlite/dist/readiness/rolloutSafety.js', )); + const { LOCAL_SQLITE_CONTRACT_VERSION } = require(path.join( + root, + 'packages/ql3-local-sqlite/dist/readiness/readinessInspection.js', + )); const temporaryRoot = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-compose-rollout-')), ); @@ -487,8 +494,9 @@ async function main() { (fs.statSync(path.join(rolloutRoot, name)).mode & 0o777) !== 0o600, ) || (fs.statSync(backupPath).mode & 0o777) !== 0o600 || - receipt.sqlite?.contractVersion !== 44 || - receipt.sqlite?.writeContractVersion !== 44 || + receipt.sqlite?.contractVersion !== LOCAL_SQLITE_CONTRACT_VERSION || + receipt.sqlite?.writeContractVersion !== + LOCAL_SQLITE_WRITE_CONTRACT_VERSION || (receipt.sqlite?.writeObservation !== 'unchanged' && receipt.sqlite?.writeObservation !== 'changed') || receipt.sqlite?.backup?.sha256 !== backup.sha256 || @@ -959,7 +967,7 @@ async function main() { composeMerge: true, rolloutActive: true, durableReceipt: true, - sqliteWriteContract: 37, + sqliteWriteContract: LOCAL_SQLITE_WRITE_CONTRACT_VERSION, sqliteBackup: true, sqliteWriteObservation: receipt.sqlite.writeObservation, sqliteRestorePrepared: true, diff --git a/scripts/ql3-release-deployment-readiness-contract.cjs b/scripts/ql3-release-deployment-readiness-contract.cjs index 9ddb836c..dab887da 100644 --- a/scripts/ql3-release-deployment-readiness-contract.cjs +++ b/scripts/ql3-release-deployment-readiness-contract.cjs @@ -206,7 +206,9 @@ function validateLocalReport(entry, profile, consumption, identity) { report.composeMerge !== true || report.rolloutActive !== true || report.durableReceipt !== true || - report.sqliteWriteContract !== 37 || + !Number.isSafeInteger(report.sqliteWriteContract) || + report.sqliteWriteContract < 1 || + report.sqliteWriteContract > 65_535 || report.sqliteBackup !== true || report.sqliteRestorePrepared !== true || report.sqliteRestoreCommitted !== true || @@ -325,6 +327,9 @@ function createDeploymentReadinessReceipt(input) { ) { fail('local profiles used different release selections'); } + if (edge.sqliteWriteContract !== standalone.sqliteWriteContract) { + fail('local profiles used different SQLite write contracts'); + } evidence.push({ family: 'local', catalogConsumptionDigest: localConsumption.contentDigest, diff --git a/test/back/ql3LocalComposeCatalogReleaseSelection.test.cjs b/test/back/ql3LocalComposeCatalogReleaseSelection.test.cjs index 5c26a36e..203a3c17 100644 --- a/test/back/ql3LocalComposeCatalogReleaseSelection.test.cjs +++ b/test/back/ql3LocalComposeCatalogReleaseSelection.test.cjs @@ -13,6 +13,7 @@ const { writeSyntheticLocalReleaseSelection, } = require('../../scripts/lib/ql3-local-release-selection-test-fixture.cjs'); +const ROOT = path.resolve(__dirname, '../..'); const IMAGE = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat( 64, )}`; @@ -178,3 +179,20 @@ test('marks an ordinary PR rollout as a synthetic fixture', (t) => { assert.deepEqual(result.authority, { mode: 'synthetic_live_fixture' }); assert.equal(fs.existsSync(result.releaseSelection.path), true); }); + +test('live Compose gates consume the built SQLite contract version', () => { + const preflight = fs.readFileSync( + path.join(ROOT, 'scripts/ql3-local-compose-preflight-live-contract.cjs'), + 'utf8', + ); + const rollout = fs.readFileSync( + path.join(ROOT, 'scripts/ql3-local-compose-rollout-live-contract.cjs'), + 'utf8', + ); + for (const source of [preflight, rollout]) { + assert.match(source, /LOCAL_SQLITE_CONTRACT_VERSION/); + assert.doesNotMatch(source, /contractVersion !== [0-9]+/); + } + assert.match(rollout, /LOCAL_SQLITE_WRITE_CONTRACT_VERSION/); + assert.doesNotMatch(rollout, /sqliteWriteContract: [0-9]+/); +}); diff --git a/test/back/ql3ReleaseDeploymentReadinessContract.test.cjs b/test/back/ql3ReleaseDeploymentReadinessContract.test.cjs index f3871588..3504e318 100644 --- a/test/back/ql3ReleaseDeploymentReadinessContract.test.cjs +++ b/test/back/ql3ReleaseDeploymentReadinessContract.test.cjs @@ -75,7 +75,7 @@ function localReport(scope, catalog, profile) { composeMerge: true, rolloutActive: true, durableReceipt: true, - sqliteWriteContract: 37, + sqliteWriteContract: 50, sqliteBackup: true, sqliteWriteObservation: { committed: true }, sqliteRestorePrepared: true, @@ -259,6 +259,9 @@ test('rejects synthetic, incomplete and unclean deployment reports', () => { (input) => { input.local.standalone.value.gracefulCleanup = false; }, + (input) => { + input.local.edge.value.sqliteWriteContract = 0; + }, (input) => { input.cluster.report.value.receiptAuditCompatible = false; }, @@ -275,6 +278,15 @@ test('rejects synthetic, incomplete and unclean deployment reports', () => { } }); +test('requires one bounded SQLite write contract across Local profiles', () => { + const input = structuredClone(fixture('local').input); + input.local.standalone.value.sqliteWriteContract = 49; + assert.throws( + () => createDeploymentReadinessReceipt(input), + /different SQLite write contracts/, + ); +}); + test('rejects tampered, recomputed or authority-detached receipts', () => { const { receipt } = fixture('all'); const tampered = structuredClone(receipt); diff --git a/test/back/ql3ReleasePublicationClosureContract.test.cjs b/test/back/ql3ReleasePublicationClosureContract.test.cjs index 53ee5812..aef51572 100644 --- a/test/back/ql3ReleasePublicationClosureContract.test.cjs +++ b/test/back/ql3ReleasePublicationClosureContract.test.cjs @@ -137,7 +137,7 @@ function localReport(profile, consumption, marker) { composeMerge: true, rolloutActive: true, durableReceipt: true, - sqliteWriteContract: 37, + sqliteWriteContract: 50, sqliteBackup: true, sqliteWriteObservation: { rows: 1 }, sqliteRestorePrepared: true,