feat(ql3): revalidate release evidence at closure

This commit is contained in:
whyour
2026-08-18 02:41:41 +08:00
parent cc6d6b7b48
commit 7ac5e678ba
11 changed files with 269 additions and 5 deletions
+18
View File
@@ -11,6 +11,24 @@
最新增量证据(2026-08-18):
- D-348/ADR-0440(已接受;首份真实线上闭合待实际 release tag):补齐 D-347 确定性收据与实际 tag promotion 之间的 freshness 窗口。此前私有
job 在收据创建时验证 24 小时 freshness,但 release-set aggregate 只检查 `observedAt` 可解析与 receipt digest;镜像构建、扫描和 attestation
延迟后,创建时有效的收据可能在闭合时已经过期。现在 `cluster|all` aggregate 与紧随其后的 source-record audit 必须各自取得 runner-owned 当前
时钟,对两份 receipt 公开的 `observedAt` 重新执行 24 小时最大年龄和五分钟未来偏差门;缺失/非安全整数/过期/未来漂移均在 release-set 写文件、
tag promotion 和 catalog publication 前失败关闭。当前时钟没有 CLI 参数、环境变量或 durable 字段,只参与瞬时准入;因此同一 source evidence 在
两个有效闭合时钟下仍生成逐字节相同的 receipt v2、release-set v3 与 catalog identity。Local scope 保持零私有收据且该门为
`not_applicable`;历史 standalone inspection 继续验证结构/identity/self-digest 而不以今天的时钟淘汰合法 catalog,并诚实声明未重放 source
records/private reports。本 Gate 不修改 durable schema/media type,不新增 package、生产依赖、数据库、migration、Pod、controller、RBAC、
listener、timer、设备工具或稳态资源。定向 release receipt/set/catalog/consumption/deployment-lock/image audit 回归 137/137;完整 backend
1,365 项为 1,363 pass/2 条件 skip/0 fail18-package clean build/test 退出 0。12 项 package boundary、Cluster dependency、Edge
import、Cluster/Worker/CloudNativePG 部署、backup、Barman/cert-manager selection、image release、deployment-lock surface 与
release-version 审计全部 compatible14 档 Local artifact 全部 compatible 且保持既有字节基线:默认 Edge/Standalone 为
2,589,890/2,589,968 bytesadopted 为 2,809,185/2,809,308 bytesapplication 为 3,632,769/3,632,889 bytes
application-api 为 3,800,322/3,800,466 bytesAI 为 3,069,143/3,069,233 bytesapplication+AI 为
4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytes。真实 PostgreSQL 18.6 arm64 physical HA 再次通过 142/142、
timeline `1→2`,报告 SHA-256 为 `7566a54f86f3e4e0fee2096a49ea2877d827595575c7075bc44b65314cb2ba19`,离线审计通过且无
`ql3-ha-*` Docker 容器、卷或网络残留;真实线上 closure 仍只能由实际受保护 `v3` release tag 取得。
- D-347/ADR-0439(已接受;首份真实线上重放待实际 release tag):修正 D-346 收据把私有 runner `validatedAt` wall-clock 写入 durable
JSON 导致同一 source/report 在 workflow 重跑时产生第二个 release identity 的问题。私有收据升为
`qinglong/private-release-evidence-receipt@v2`:创建时仍以当前私有时钟执行 source-aware gate 和 24 小时 freshness 双重验证,但 durable
@@ -0,0 +1,75 @@
# ADR-0440Release-set 闭合时私有证据 Freshness 重验证
- 状态:Accepted
- 日期:2026-08-18
- 关联 RFCQL-RFC-0001 D-03、D-14、D-335、D-346、D-347、D-348
- 关联 ADRADR-0427、ADR-0438、ADR-0439
- ExtendsADR-0439 的确定性私有证据收据与 release-set replay
## 上下文
ADR-0439 从私有证据收据中移除了 runner `validatedAt` wall-clock,使同一 release identity、私有报告和静态 lock 在合法 workflow 重跑时生成相同
receipt、release-set 与 catalog digest。创建私有收据时仍会用当前私有 runner 时钟执行 24 小时 freshness gate。
但 release-set job 可能在镜像构建、扫描、签名和 attestation 后才消费短期收据。D347 的聚合路径只验证 `observedAt` 可解析、receipt identity 和
self digest,没有在 tag promotion 前以闭合时钟重新计算年龄。GitHub artifact 的一天 retention 是存储配置,不是精确的证据 freshness authority
收据在创建时有效,不代表在 release-set 闭合时仍有效。
## 决策
1. `cluster|all` release-set closure 必须取得一个 runner-owned、safe-integer validation clock。该时钟不是 release-set input field,也不能由 CLI
参数、环境变量、image record 或私有 receipt 指定。
2. `inspectPrivateReleaseEvidenceReceipt` 在 closure caller 显式提供 validation clock 时,除 exact shape/source/report/self-digest 外,还必须用公开的
`observedAt` 重新执行 24 小时最大年龄和五分钟未来偏差门。
3. release-set `aggregate` 与紧随其后的 source-record `audit` 各自从进程内当前时钟执行该门;缺失、非安全整数、过期或未来漂移均在写 release-set、
tag promotion 和 catalog publication 前失败关闭。
4. validation clock 只参与准入,不进入 receipt、release-set、catalog plan、OCI manifest 或 digest。两个不同但都在有效窗口内的闭合时钟必须生成
逐字节相同的 release-set。
5. `local` scope 必须保持零私有收据,closure freshness 为 `not_applicable`,不要求该时钟即可创建同一 Local release-set。
6. `inspectReleaseSet` 是历史 durable catalog 的 standalone verifier,不以当前日期淘汰曾经合法的发布。它继续验证结构、identity、image/receipt 顺序与
self digest,并诚实声明 source records/private reports 未重放;当前年龄不能证明过去发布时的 freshness。
7. 不升级 receipt v2、release-set v3 或 OCI v3 media type,因为 durable bytes 和消费者结构没有变化;本 Gate 只收紧创建/审计时可接受的瞬时输入。
## 失败与恢复
- 收据在创建时有效、闭合时超过 24 小时:aggregate 在任何 durable 输出和 tag mutation 前失败,必须重新取得 source-aware 私有报告及其确定性收据。
- 收据时间超过闭合时钟五分钟:视为时钟/报告异常并失败,不能通过推迟发布等待其“自然有效”。
- aggregate response-loss 后以同一 source evidence 重跑:新的闭合时钟若仍在窗口内,生成相同 bytes;若已过期则必须重新取得私有证据,而不是复用旧收据。
- 历史 catalog 多年后验真:standalone inspection 仍可通过 structural/digest/provenance 链;不得声称重新执行了当年的私有 freshness gate。
- Local 发布:没有私有 evidence receipt,不执行 Cluster freshness 语义,也不新增设备或发布 runner 工作。
## 部署与资源影响
- Edge/Standalone 设备、Local image、selection 和 Compose 路径零变化,不安装发布工具,不增加 CPU/RSS、I/O、timer、listener 或 updater。
- Cluster 节点和 Kubernetes object 零变化;重验证只发生在短生命周期 release-set runner,成本为两次时间读取和两个时间戳的常数比较。
- 不新增 workspace package、生产依赖、数据库、migration、SQL、Pod、controller、RBAC、Secret、网络连接或持久状态。
## 被拒绝的替代方案
### 重新把 `validatedAt` 写入 receipt
拒绝。它会恢复 D347 已消除的 wall-clock digest 漂移,让合法 workflow retry 产生第二个 catalog identity。
### 依赖一天的 GitHub artifact retention
拒绝。retention 是删除上限,不保证下载瞬间的精确年龄、未来偏差或 tag promotion 前重验证,也不是 release contract 的可测试语义。
### 让 CLI 传入 `--validation-clock`
拒绝。发布者可选择旧时钟会把 freshness gate 变成自报事实。测试通过显式 dependency injection 驱动边界,生产 CLI 不开放该输入面。
### 在所有历史 catalog inspection 中按当前时间拒绝
拒绝。这样所有合法发布都会在 24 小时后不可审计,并把“当前仍新鲜”错误等同于“发布时曾通过 freshness gate”。
## 验证
- 同一 Cluster source evidence 在两个不同有效 closure clock 下生成对象和 canonical bytes 完全相同;
- 超过 24 小时、未来偏差和缺失 validation clock 均在 release-set 写入前失败;
- CLI production path 使用内部 clock,测试证明 stale retry 不产生输出;
- Local scope、历史 standalone inspection、catalog consumption 和 deployment lock 保持兼容;
- 定向发布链 137/137,完整 backend 1,365 项为 1,363 pass/2 条件 skip/0 fail18-package clean build/test 退出 0
- 12 项静态审计和 14 档 Local artifact 全部 compatibleEdge/Standalone 至 MCP 的既有制品字节与 RSS 上限未漂移;
- PostgreSQL 18.6 arm64 physical HA 通过 142/142、timeline `1→2`,证据 SHA-256 为
`7566a54f86f3e4e0fee2096a49ea2877d827595575c7075bc44b65314cb2ba19`,离线审计 compatible 且 Docker 资源零残留;
- 完整证据记录于 QL-RFC-0001 D-348;首份真实线上 closure 仍须由受保护 `v3` release tag 产生。
+1
View File
@@ -443,6 +443,7 @@
| [ADR-0437](./ADR-0437-post-publication-catalog-bound-local-release-gate.md) | 发布后 Catalog-bound Local Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag |
| [ADR-0438](./ADR-0438-content-free-private-release-evidence-receipt-chain.md) | 内容无关的私有发布证据收据链 | Accepted(首份真实公开收据待实际 release tag |
| [ADR-0439](./ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md) | 确定性私有证据收据与 Release-set 重放 | Accepted(首份真实线上重放待实际 release tag |
| [ADR-0440](./ADR-0440-release-set-closure-private-evidence-freshness.md) | Release-set 闭合时私有证据 Freshness 重验证 | Accepted(首份真实线上闭合待实际 release tag |
## 规则
@@ -26,6 +26,14 @@ cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 w
从私有 job 交给 release-set job,随后完整嵌入 release-set v3 并由 durable catalog 长期保护。公开收据同时声明
`freshnessValidatedAtCreation=true``durableValidationClockPublished=false`,避免把未发布的临时时钟伪装成可离线重放的现场证据。
创建时通过不等于可以无限期等待再闭合发布。`cluster|all` 的 release-set aggregate 与紧随其后的 independent audit 会各自从 runner 内部取得当前
时钟,并对两份收据公开的 `observedAt` 重新执行同一 24 小时最大年龄和五分钟未来偏差门;当前时钟没有 CLI 参数、环境变量或 durable JSON 字段。
任一收据在闭合时已过期,release-set 文件会在写入、tag promotion 与 catalog publication 前失败关闭。`local` scope 没有私有收据,因此该门为
`not_applicable`,不会把 Cluster 私有证据成本带到 Local 发布。
长期 catalog 的 standalone inspection 故意不以“今天的时钟”拒绝历史发布;它验证 receipt/release/source/self-digest 闭包,但诚实返回私有现场
证据未重放。发布时 freshness 由受保护 workflow 的 aggregate/audit 与 provenance 约束,历史 consumer 不得把当前年龄检查冒充当时的现场验证。
## 发布流水线内置 Local 与 Cluster 下游门
`local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog
@@ -1304,6 +1304,7 @@ function auditReleaseWorkflow(source) {
sameRunRecords: true,
sameRunPrivateEvidenceReceipts: true,
deterministicPrivateEvidenceReceipts: true,
privateEvidenceFreshnessRevalidatedAtClosure: true,
exactScopeClosure: true,
standaloneInspection: true,
tagPromotionAuthority: 'complete_verified_release_set',
@@ -382,9 +382,20 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
) {
fail('receipt shape or release binding is invalid');
}
if (!Number.isFinite(Date.parse(actual.evidence.observedAt))) {
const observedAtMs = Date.parse(actual.evidence.observedAt);
if (!Number.isFinite(observedAtMs)) {
fail('receipt freshness binding is invalid');
}
if (options.validationClockMs !== undefined) {
const validationClockMs = options.validationClockMs;
if (
!Number.isSafeInteger(validationClockMs) ||
observedAtMs > validationClockMs + MAX_FUTURE_SKEW_MS ||
validationClockMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
) {
fail('receipt is outside the release freshness window');
}
}
const { receiptDigest, ...unsigned } = actual;
if (
!DIGEST_PATTERN.test(receiptDigest || '') ||
+23 -2
View File
@@ -245,11 +245,14 @@ function expectedEvidenceKinds(releaseScope) {
return releaseScope === 'local' ? [] : [...EVIDENCE_KINDS];
}
function validateEvidenceReceipts(receipts, candidate) {
function validateEvidenceReceipts(receipts, candidate, validationClockMs) {
if (!Array.isArray(receipts)) {
fail('private release evidence receipts must be an array');
}
const expectedKinds = expectedEvidenceKinds(candidate.release.scope);
if (expectedKinds.length > 0 && !Number.isSafeInteger(validationClockMs)) {
fail('private evidence closure validation clock is invalid');
}
if (receipts.length !== expectedKinds.length) {
fail('private release evidence receipt count differs from release scope');
}
@@ -268,6 +271,7 @@ function validateEvidenceReceipts(receipts, candidate) {
sourceRef: candidate.release.sourceRef,
releaseScope: candidate.release.scope,
evidenceKind,
validationClockMs,
});
receiptsByKind.set(evidenceKind, receipt);
}
@@ -282,6 +286,7 @@ function createReleaseSet(options) {
const evidenceReceipts = validateEvidenceReceipts(
options.evidenceReceipts,
candidate,
options.validationClockMs,
);
if (!Array.isArray(options.records)) fail('image records must be an array');
if (options.records.length !== candidate.images.length) {
@@ -363,6 +368,8 @@ function auditReleaseSet(actual, options) {
evidenceReceiptDigests: Object.freeze(
actual.evidenceReceipts.map((entry) => entry.receiptDigest),
),
privateEvidenceFreshnessRevalidatedAtClosure:
actual.evidenceReceipts.length === 0 ? 'not_applicable' : true,
tagPromotionAuthority: actual.promotion.authority,
});
}
@@ -646,7 +653,12 @@ function parseArguments(argv) {
});
}
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
function runCli(
argv,
root = DEFAULT_ROOT,
output = process.stdout,
dependencies = { now: Date.now },
) {
const options = parseArguments(argv);
if (options.mode === 'inspect') {
const report = readCanonicalJson(options.report, 'release set');
@@ -666,12 +678,20 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
options.evidenceReceipts,
candidate,
);
let validationClockMs;
if (candidate.release.scope !== 'local') {
if (typeof dependencies?.now !== 'function') {
fail('release closure clock dependency is invalid');
}
validationClockMs = dependencies.now();
}
if (options.mode === 'aggregate') {
const releaseSet = createReleaseSet({
...options,
candidate,
records,
evidenceReceipts,
validationClockMs,
root,
});
writeNoReplace(options.output, releaseSet);
@@ -684,6 +704,7 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
candidate,
records,
evidenceReceipts,
validationClockMs,
root,
});
output.write(canonicalJson(audit));
@@ -100,6 +100,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
sameRunRecords: true,
sameRunPrivateEvidenceReceipts: true,
deterministicPrivateEvidenceReceipts: true,
privateEvidenceFreshnessRevalidatedAtClosure: true,
exactScopeClosure: true,
standaloneInspection: true,
tagPromotionAuthority: 'complete_verified_release_set',
@@ -102,6 +102,7 @@ function releaseSet(scope) {
records,
evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release),
...identity,
validationClockMs: Date.parse('2026-08-18T00:05:00.000Z'),
releaseScope: scope,
});
}
@@ -70,6 +70,7 @@ function releaseSet(scope) {
records,
evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release),
...identity,
validationClockMs: Date.parse('2026-08-18T00:05:00.000Z'),
releaseScope: scope,
});
}
+128 -2
View File
@@ -30,6 +30,7 @@ const identity = Object.freeze({
sourceRevision: 'b'.repeat(40),
sourceRef: `refs/tags/v${version}`,
repositoryOwner: 'qinglong-release',
validationClockMs: Date.parse('2026-08-18T00:05:00.000Z'),
});
function candidate(scope) {
@@ -74,12 +75,13 @@ function writeCanonical(filePath, value) {
test('aggregates the independent Local image into one immutable release set', () => {
const releaseCandidate = candidate('local');
const records = recordsFor(releaseCandidate);
const { validationClockMs: _unused, ...localIdentity } = identity;
const releaseSet = createReleaseSet({
root,
candidate: releaseCandidate,
records,
evidenceReceipts: evidenceFor(releaseCandidate),
...identity,
...localIdentity,
releaseScope: 'local',
});
assert.deepEqual(
@@ -97,7 +99,7 @@ test('aggregates the independent Local image into one immutable release set', ()
candidate: releaseCandidate,
records,
evidenceReceipts: evidenceFor(releaseCandidate),
...identity,
...localIdentity,
releaseScope: 'local',
}).compatible,
true,
@@ -187,6 +189,66 @@ test('requires exact private evidence receipts only for Cluster-capable scopes',
);
});
test('revalidates private evidence freshness at closure without changing release-set bytes', () => {
const releaseCandidate = candidate('cluster');
const records = recordsFor(releaseCandidate);
const evidenceReceipts = evidenceFor(releaseCandidate);
const createAt = (validationClockMs) =>
createReleaseSet({
root,
candidate: releaseCandidate,
records,
evidenceReceipts,
...identity,
validationClockMs,
releaseScope: 'cluster',
});
const first = createAt(Date.parse('2026-08-18T00:05:00.000Z'));
const second = createAt(Date.parse('2026-08-18T00:15:00.000Z'));
assert.deepEqual(second, first);
assert.throws(
() => createAt(Date.parse('2026-08-19T00:00:01.000Z')),
/release freshness window/,
);
assert.throws(
() => createAt(Date.parse('2026-08-17T23:54:59.000Z')),
/release freshness window/,
);
assert.throws(
() =>
createReleaseSet({
root,
candidate: releaseCandidate,
records,
evidenceReceipts,
version: identity.version,
sourceRevision: identity.sourceRevision,
sourceRef: identity.sourceRef,
repositoryOwner: identity.repositoryOwner,
releaseScope: 'cluster',
}),
/closure validation clock/,
);
assert.equal(
auditReleaseSet(first, {
root,
candidate: releaseCandidate,
records,
evidenceReceipts,
...identity,
releaseScope: 'cluster',
}).privateEvidenceFreshnessRevalidatedAtClosure,
true,
);
assert.equal(
inspectReleaseSet(first, {
...identity,
releaseScope: 'cluster',
}).privateEvidenceReplayed,
false,
);
});
test('rejects missing, duplicate and cross-candidate image evidence', () => {
const releaseCandidate = candidate('cluster');
const records = recordsFor(releaseCandidate);
@@ -440,6 +502,70 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => {
);
});
test('CLI rejects stale private receipts at closure and keeps valid retries byte-identical', (t) => {
const directory = temporaryDirectory(t);
const recordsDirectory = path.join(directory, 'records');
const evidenceDirectory = path.join(directory, 'evidence');
fs.mkdirSync(recordsDirectory);
fs.mkdirSync(evidenceDirectory);
const releaseCandidate = candidate('cluster');
const candidatePath = path.join(directory, 'candidate.json');
writeCanonical(candidatePath, releaseCandidate);
for (const record of recordsFor(releaseCandidate)) {
writeCanonical(
path.join(recordsDirectory, `${record.image.name}.json`),
record,
);
}
for (const receipt of evidenceFor(releaseCandidate)) {
writeCanonical(
path.join(evidenceDirectory, `${receipt.evidenceKind}.json`),
receipt,
);
}
const common = [
'--mode=aggregate',
`--version=${version}`,
`--source-revision=${identity.sourceRevision}`,
`--source-ref=${identity.sourceRef}`,
'--release-scope=cluster',
`--repository-owner=${identity.repositoryOwner}`,
`--candidate=${candidatePath}`,
`--records=${recordsDirectory}`,
`--evidence-receipts=${evidenceDirectory}`,
];
const firstPath = path.join(directory, 'first.json');
const secondPath = path.join(directory, 'second.json');
runCli(
[...common, `--output=${firstPath}`],
root,
{ write() {} },
{ now: () => Date.parse('2026-08-18T00:05:00.000Z') },
);
runCli(
[...common, `--output=${secondPath}`],
root,
{ write() {} },
{ now: () => Date.parse('2026-08-18T00:15:00.000Z') },
);
assert.equal(
fs.readFileSync(secondPath, 'utf8'),
fs.readFileSync(firstPath, 'utf8'),
);
const stalePath = path.join(directory, 'stale.json');
assert.throws(
() =>
runCli(
[...common, `--output=${stalePath}`],
root,
{ write() {} },
{ now: () => Date.parse('2026-08-19T00:00:01.000Z') },
),
/release freshness window/,
);
assert.equal(fs.existsSync(stalePath), false);
});
test('CLI rejects extra records, symlinks and open argument shapes', (t) => {
const directory = temporaryDirectory(t);
const recordsDirectory = path.join(directory, 'records');