diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 3a239b3a..cb1c44e4 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,24 @@ 最新增量证据(2026-08-18): +- 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 + projection 只保留不可变报告自身的 `observedAt`、report/static-lock/self digest,并显式声明 `freshnessValidatedAtCreation=true`、 + `durableValidationClockPublished=false` 与公开 consumer 不能重放现场报告。相同 release identity、相同私有报告字节和相同 source-controlled + static locks 在不同有效运行时间必须生成逐字节相同收据。由于嵌入协议发生不兼容变化,release-set/OCI artifact media type 同步升为 + `qinglong/release-set@v3`/`application/vnd.qinglong.release-set.v3+json`,不在 v2 下偷换语义;catalog recovery 收紧为 + `republish_deterministic_content_then_verify_digest`,只允许相同不可变输入的同内容重放。Local 仍为零私有收据,新增工作只在短生命周期 + release runner 发生,不新增 package、依赖、数据库、migration、Pod、controller、listener、timer、设备工具或稳态资源。定向发布链 + 135/135;完整 backend 1,363 项为 1,361 pass/2 条件 skip/0 fail,18-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 审计全部 compatible;14 档 Local artifact 与既有字节基线完全一致:默认 Edge/Standalone 为 + 2,589,890/2,589,968 bytes,adopted 为 2,809,185/2,809,308 bytes,application 为 3,632,769/3,632,889 bytes,application-api 为 + 3,800,322/3,800,466 bytes,AI 为 3,069,143/3,069,233 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 + 7,315,930/7,316,038 bytes。阶段完整性 PostgreSQL HA 以 18.6/arm64 重跑 142/142 gates、timeline `1→2`,独立 evidence audit + compatible,mode-0600 报告 SHA-256 为 `db72086e971fc91c25a4a482af6634acbf4d463e567c44f42af0e7d0cc24fca2`, + container/network/volume 残留为 0。真实线上 response-loss 重放仍只能由实际受保护 `v3` release tag 取得。 + - D-346/ADR-0438(已接受;首份真实公开收据待实际 release tag):Cluster/All 发布不再只通过 GitHub `needs` 保存“两个私有 evidence job 成功”的瞬时调度事实。Worker management 与 CloudNativePG DR 私有 runner 在 source-aware gate 成功后,各自产生一个 `qinglong/private-release-evidence-receipt@v1`:只投影 exact version/source tag/revision/scope、evidence kind、24 小时 freshness、 diff --git a/docs/adr/ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md b/docs/adr/ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md new file mode 100644 index 00000000..b869170b --- /dev/null +++ b/docs/adr/ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md @@ -0,0 +1,76 @@ +# ADR-0439:确定性私有证据收据与 Release-set 重放 + +- 状态:Accepted +- 日期:2026-08-18 +- 关联 RFC:QL-RFC-0001 D-03、D-14、D-335、D-336、D-346、D-347 +- 关联 ADR:ADR-0427、ADR-0428、ADR-0430、ADR-0438 +- Supersedes:ADR-0438 的 `qinglong/private-release-evidence-receipt@v1` 与 `qinglong/release-set@v2` 聚合版本 + +## 上下文 + +ADR-0438 把 Worker management 与 CloudNativePG disaster-recovery 私有报告投影为 content-free 收据,并将两份收据纳入 durable +release-set。v1 收据同时持久化 `observedAt` 和私有 job 实际运行时的 `validatedAt`。后者来自 runner wall-clock,不属于不可变 source +evidence。 + +因此,同一 tag、同一 source revision、同一报告与同一静态 lock 只要在不同时间重跑,就会产生不同 receipt digest、release-set digest 和 OCI +catalog manifest digest。这与 catalog plan 已冻结的 `republish_same_content_then_verify_digest` 恢复语义矛盾:一次合法的 workflow response-loss +恢复可能生成第二个 release identity,甚至让无 authority 的 discovery tag 指向不同内容。 + +## 决策 + +1. 私有收据升级为 `qinglong/private-release-evidence-receipt@v2`,`schemaVersion=2`。收据继续绑定报告自身的 `observedAt`、24 小时最大年龄、 + report digest、release identity、evidence kind、静态审计摘要和 self digest,但不持久化 runner validation wall-clock。 +2. 创建路径仍必须取得当前私有 runner 时钟,并由既有 source-aware gate 以及 receipt assembler 双重验证:报告不得超过 24 小时,最多只允许五分钟 + 未来时钟偏差。移除 durable clock 绝不等于移除 freshness gate。 +3. 收据显式声明 `freshnessValidatedAtCreation=true` 与 `durableValidationClockPublished=false`。公开消费者可验证结构、source/report equality 和 + digest 闭包,但仍必须保持 `publicConsumerReplay=not_possible_without_private_reports`,不能把布尔声明冒充现场重放。 +4. 相同 release identity、相同私有报告字节和相同 source-controlled static locks,无论私有 job 在有效窗口内何时重跑,必须生成逐字节相同的 + receipt。报告自身使用 owner-private、mode `0600`、commit-scoped、no-replace 发布路径;不同报告 digest 不是同一次恢复输入。 +5. release-set 升级为 `qinglong/release-set@v3`,OCI artifact/file media type 同步升级为 + `application/vnd.qinglong.release-set.v3+json`。不能在 v2 release-set 或 media type 下静默替换嵌入收据的 schema 与字段。 +6. catalog recovery 语义收紧为 `republish_deterministic_content_then_verify_digest`:只允许相同 source evidence 的确定性内容重发并回读同一 digest; + source/report/static-lock 发生变化时必须作为新输入失败关闭,不能解释为同一发布的 response-loss replay。 +7. 不新增 package、production dependency、数据库、migration、Pod、controller、listener、timer、设备工具或稳态资源。变化只影响短生命周期 + release runner 的 JSON 字段和 SHA-256;Local scope 仍为零私有收据。 + +## 失败与恢复 + +- 当前时钟超出 freshness window:创建在写文件前失败;不得因 durable JSON 不含 wall-clock 而放宽。 +- 同一 source report 重跑:v2 收据、v3 release-set、catalog plan 与 manifest 内容必须逐字节一致,可安全重发并验证同 digest。 +- 同一 source 路径内容被替换:私有 no-replace/stable descriptor 边界或 report digest 闭包失败;不能覆盖既有发布身份。 +- v1 receipt、v2 release-set 或 v2 media type:3.0 尚未正式发布,不做隐式迁移;从原始私有证据重新运行受保护 release workflow。 +- 历史 catalog consumer:只接受 v3 release-set;不会把 v1/v2 解释成当前 schema。 + +## 部署与资源影响 + +- Edge/Standalone 设备下载的仍是一个 Local image digest 和小型 selection;不安装证据工具,不增加常驻 CPU/RSS、I/O 或连接。 +- Cluster 节点仍只消费 catalog-bound image references;确定性发生在发布工作站,不引入 Kubernetes workload 或运行期控制面。 +- v2 收据比 v1 少一个时间字符串,并增加两个固定 boolean;差异不改变制品预算或包拓扑。 + +## 被拒绝的替代方案 + +### 保留 `validatedAt` 并允许重跑产生新 catalog + +拒绝。同一不可变 release source 将拥有多个仅由 runner 时钟区分的 durable identity,response-loss 无法与新发布区分。 + +### 把 `validatedAt` 固定为 `observedAt` + +拒绝。两个字段相同会伪称验证与观察发生在同一时刻。删除未发布的 ephemeral clock,并明确声明其未持久化,更符合证据强度。 + +### 删除 freshness 检查以换取确定性 + +拒绝。确定性只约束 durable projection;私有创建仍必须以当前时钟重放 source-aware gate。 + +### 保持 release-set v2 与 OCI v2 media type + +拒绝。嵌入对象从 receipt v1 变为 v2 是消费者可观察的协议变化,复用旧 schema/media type 会隐藏不兼容语义。 + +## 验证 + +- receipt contract 必须证明相同报告在两个不同有效 validation clock 下生成相同对象和相同 canonical bytes,并拒绝重新注入 `validatedAt`; +- 既有 source/scope/report/static-lock/self-digest、私有字段、mode `0600`、stable read 与 no-replace 负向门全部保留; +- release-set、catalog、consumption、deployment-lock 与 workflow 审计必须只接受 receipt v2、release-set v3 和 OCI v3 media type; +- 定向发布链 135/135,完整 backend 1,363 项为 1,361 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,12 项静态审计与 14 档 + Local artifact 均 compatible 且制品字节基线不变;PostgreSQL 18.6 arm64 HA 为 142/142、timeline `1→2`,独立报告审计 compatible,且 + Docker container/network/volume 零残留; +- 完整测试、制品与运行门结果记录于 QL-RFC-0001 D-347;首份真实线上重放仍须由受保护 `v3` release tag 产生。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 554d81e2..190fae5c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -442,6 +442,7 @@ | [ADR-0436](./ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md) | 发布后 Catalog-bound Kubernetes Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag) | | [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) | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index beeacb78..d82c9a1b 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -15,14 +15,16 @@ ghcr.io//qinglong3-release-catalog:v- ## 私有发布证据收据链 -当前长期 authority 是 `qinglong/release-set@v2`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含 -`worker-management` 与 `cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v1`。每份收据绑定同一 +当前长期 authority 是 `qinglong/release-set@v3`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含 +`worker-management` 与 `cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v2`。每份收据绑定同一 version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digest;DR 收据还绑定 CloudNativePG backup、Barman Cloud 与 -cert-manager 三项静态审计摘要。 +cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 wall-clock:创建时仍必须以当前时钟完成 freshness gate,但 durable JSON 只绑定 +不可变报告的 `observedAt`。因此相同 source/report/static-lock 在 workflow 重跑时逐字节相同,不会仅因重跑时间变化而生成第二个 catalog identity。 这些收据不包含原始生产报告、路径、credential、token、Kubernetes object 或 transcript。公开 consumer 可以重算收据和 release-set digest, 但必须保持 `publicConsumerReplay=not_possible_without_private_reports`;它不能声称重放了私有现场结果。原始报告不上传,只有收据以 1 天 artifact -从私有 job 交给 release-set job,随后完整嵌入 release-set v2 并由 durable catalog 长期保护。 +从私有 job 交给 release-set job,随后完整嵌入 release-set v3 并由 durable catalog 长期保护。公开收据同时声明 +`freshnessValidatedAtCreation=true` 与 `durableValidationClockPublished=false`,避免把未发布的临时时钟伪装成可离线重放的现场证据。 ## 发布流水线内置 Local 与 Cluster 下游门 @@ -404,7 +406,7 @@ SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计 1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable reference;discovery tag 无 authority。 2. materializer 只能接受完整 `qinglong/release-catalog-consumption-ceremony@v1` bundle,不能接受旧的松散 `--release-set`;其中 - `qinglong/release-set@v2` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。 + `qinglong/release-set@v3` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。 3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。 4. Local scope 必须为零私有收据;Cluster/All 必须精确包含两份同 source、同 scope、自摘要有效且 freshness 闭合的 content-free 收据。 static lock compatible 不等于现场证据已公开重放,任何 consumer 都必须保留该限制。 diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 4cf806bf..4404ac55 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -1303,6 +1303,7 @@ function auditReleaseWorkflow(source) { sourceDerived: true, sameRunRecords: true, sameRunPrivateEvidenceReceipts: true, + deterministicPrivateEvidenceReceipts: true, exactScopeClosure: true, standaloneInspection: true, tagPromotionAuthority: 'complete_verified_release_set', @@ -1312,7 +1313,7 @@ function auditReleaseWorkflow(source) { }, durableCatalog: { repository: 'qinglong3-release-catalog', - artifactType: 'application/vnd.qinglong.release-set.v2+json', + artifactType: 'application/vnd.qinglong.release-set.v3+json', basenameOnly: true, crossRunnerDeterministic: true, byteExactRoundTrip: true, diff --git a/scripts/ql3-private-release-evidence-receipt-contract.cjs b/scripts/ql3-private-release-evidence-receipt-contract.cjs index 9dd47839..80ca65f7 100644 --- a/scripts/ql3-private-release-evidence-receipt-contract.cjs +++ b/scripts/ql3-private-release-evidence-receipt-contract.cjs @@ -21,7 +21,7 @@ const { auditCertManagerSelection, } = require('./ql3-cert-manager-selection-audit.cjs'); -const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1'; +const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v2'; const EVIDENCE_KINDS = Object.freeze([ 'worker-management', 'cloudnativepg-disaster-recovery', @@ -182,12 +182,12 @@ function assembleReceipt(options) { fail('private evidence report digest is invalid'); } const observedAtMs = Date.parse(options.observedAt); - const validatedAtMs = options.nowMs; + const validationClockMs = options.nowMs; if ( - !Number.isSafeInteger(validatedAtMs) || + !Number.isSafeInteger(validationClockMs) || !Number.isFinite(observedAtMs) || - observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS || - validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000 + observedAtMs > validationClockMs + MAX_FUTURE_SKEW_MS || + validationClockMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000 ) { fail('private evidence is outside the release freshness window'); } @@ -196,7 +196,7 @@ function assembleReceipt(options) { ? staticAuditReceipts(options.root) : Object.freeze([]); const unsigned = { - schemaVersion: 1, + schemaVersion: 2, schema: RECEIPT_SCHEMA, release: { version: options.version, @@ -208,7 +208,6 @@ function assembleReceipt(options) { evidence: { fixture: options.fixture, observedAt: options.observedAt, - validatedAt: new Date(validatedAtMs).toISOString(), maximumAgeSeconds: MAX_EVIDENCE_AGE_SECONDS, reportDigest: options.reportDigest, sourceReportsUploaded: false, @@ -217,6 +216,8 @@ function assembleReceipt(options) { verification: { sourceAwareAudit: true, privateEvidenceReplayed: true, + freshnessValidatedAtCreation: true, + durableValidationClockPublished: false, publicConsumerReplay: 'not_possible_without_private_reports', privateReportContentPublished: false, compatible: true, @@ -323,7 +324,7 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) { 'verification', 'receiptDigest', ]) || - actual.schemaVersion !== 1 || + actual.schemaVersion !== 2 || actual.schema !== RECEIPT_SCHEMA || !exactKeys(actual.release, [ 'version', @@ -342,7 +343,6 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) { !exactKeys(actual.evidence, [ 'fixture', 'observedAt', - 'validatedAt', 'maximumAgeSeconds', 'reportDigest', 'sourceReportsUploaded', @@ -363,6 +363,8 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) { !exactKeys(actual.verification, [ 'sourceAwareAudit', 'privateEvidenceReplayed', + 'freshnessValidatedAtCreation', + 'durableValidationClockPublished', 'publicConsumerReplay', 'privateReportContentPublished', 'compatible', @@ -371,6 +373,8 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) { JSON.stringify({ sourceAwareAudit: true, privateEvidenceReplayed: true, + freshnessValidatedAtCreation: true, + durableValidationClockPublished: false, publicConsumerReplay: 'not_possible_without_private_reports', privateReportContentPublished: false, compatible: true, @@ -378,14 +382,7 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) { ) { fail('receipt shape or release binding is invalid'); } - const observedAtMs = Date.parse(actual.evidence.observedAt); - const validatedAtMs = Date.parse(actual.evidence.validatedAt); - if ( - !Number.isFinite(observedAtMs) || - !Number.isFinite(validatedAtMs) || - observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS || - validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000 - ) { + if (!Number.isFinite(Date.parse(actual.evidence.observedAt))) { fail('receipt freshness binding is invalid'); } const { receiptDigest, ...unsigned } = actual; diff --git a/scripts/ql3-release-catalog-contract.cjs b/scripts/ql3-release-catalog-contract.cjs index b6738a78..1bfe1e59 100644 --- a/scripts/ql3-release-catalog-contract.cjs +++ b/scripts/ql3-release-catalog-contract.cjs @@ -12,7 +12,7 @@ const { const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1'; const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1'; -const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v2+json'; +const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v3+json'; const FILE_MEDIA_TYPE = ARTIFACT_TYPE; const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json'; const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json'; @@ -173,7 +173,7 @@ function createCatalogPlan(releaseSet, options) { discoveryTagAuthority: 'none', immutableDigestAuthority: 'required', roundTrip: 'byte_exact', - recovery: 'republish_same_content_then_verify_digest', + recovery: 'republish_deterministic_content_then_verify_digest', }, }; return Object.freeze({ diff --git a/scripts/ql3-release-set-contract.cjs b/scripts/ql3-release-set-contract.cjs index 2e266bf3..dc8b81e8 100644 --- a/scripts/ql3-release-set-contract.cjs +++ b/scripts/ql3-release-set-contract.cjs @@ -20,7 +20,7 @@ const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1'; -const RELEASE_SET_SCHEMA = 'qinglong/release-set@v2'; +const RELEASE_SET_SCHEMA = 'qinglong/release-set@v3'; const MAX_JSON_BYTES = 1024 * 1024; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u; diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index 0e8a2213..726e3d86 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -99,6 +99,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { sourceDerived: true, sameRunRecords: true, sameRunPrivateEvidenceReceipts: true, + deterministicPrivateEvidenceReceipts: true, exactScopeClosure: true, standaloneInspection: true, tagPromotionAuthority: 'complete_verified_release_set', @@ -108,7 +109,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { }, durableCatalog: { repository: 'qinglong3-release-catalog', - artifactType: 'application/vnd.qinglong.release-set.v2+json', + artifactType: 'application/vnd.qinglong.release-set.v3+json', basenameOnly: true, crossRunnerDeterministic: true, byteExactRoundTrip: true, diff --git a/test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs b/test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs index 772f5c69..57861734 100644 --- a/test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs +++ b/test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs @@ -119,6 +119,70 @@ test('rejects publishing private material in an otherwise re-digested receipt', ); }); +test('omits the ephemeral validation clock and replays identical source evidence byte-for-byte', (t) => { + const directory = temporaryDirectory(t); + const reportPath = path.join(directory, 'private-report.json'); + fs.writeFileSync( + reportPath, + `${JSON.stringify({ observedAt: '2026-08-18T00:00:00.000Z' })}\n`, + { mode: 0o600 }, + ); + const createAt = (validationTime, outputFile) => + createWorkerReceipt( + { + root: path.resolve(__dirname, '../..'), + version: RELEASE.version, + sourceRevision: RELEASE.sourceRevision, + sourceRef: RELEASE.sourceRef, + releaseScope: RELEASE.scope, + reportFile: reportPath, + ceremonyReportFile: path.join(directory, 'private-ceremony.json'), + durableAuditReportFile: path.join(directory, 'private-durable.json'), + pkiRotationReportFile: path.join(directory, 'private-pki.json'), + caRolloverReportFile: path.join(directory, 'private-ca.json'), + outputFile, + }, + { + now: () => Date.parse(validationTime), + auditGate(_options, dependencies) { + assert.equal(dependencies.now(), Date.parse(validationTime)); + return { + fixture: 'qinglong/worker-credential-management-release-gate@v1', + evidenceReportSha256: `sha256:${'5'.repeat(64)}`, + }; + }, + }, + ); + const firstPath = path.join(directory, 'first.json'); + const secondPath = path.join(directory, 'second.json'); + const first = createAt('2026-08-18T00:05:00.000Z', firstPath); + const second = createAt('2026-08-18T00:15:00.000Z', secondPath); + assert.deepEqual(second, first); + assert.equal( + fs.readFileSync(secondPath, 'utf8'), + fs.readFileSync(firstPath, 'utf8'), + ); + assert.equal(Object.hasOwn(first.evidence, 'validatedAt'), false); + assert.equal(first.verification.freshnessValidatedAtCreation, true); + assert.equal(first.verification.durableValidationClockPublished, false); + assert.throws( + () => + createAt('2026-08-19T00:00:01.000Z', path.join(directory, 'stale.json')), + /freshness window/, + ); + + const widened = JSON.parse(JSON.stringify(first)); + widened.evidence.validatedAt = '2026-08-18T00:05:00.000Z'; + assert.throws( + () => + inspectPrivateReleaseEvidenceReceipt( + widened, + options('worker-management'), + ), + /receipt shape/, + ); +}); + test('audit CLI accepts a canonical receipt and rejects open argument shapes', (t) => { const directory = temporaryDirectory(t); const receiptPath = path.join(directory, 'worker-management.json'); diff --git a/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs b/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs index bc81aa44..a46416a9 100644 --- a/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs +++ b/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs @@ -79,7 +79,7 @@ function releaseSet(scope) { const names = images.map((entry) => entry.name); const unsigned = { schemaVersion: 1, - schema: 'qinglong/release-set@v2', + schema: 'qinglong/release-set@v3', release, candidate: { schema: 'qinglong/release-candidate-contract@v1', diff --git a/test/back/ql3ReleaseCatalogContract.test.cjs b/test/back/ql3ReleaseCatalogContract.test.cjs index a5473551..435343c0 100644 --- a/test/back/ql3ReleaseCatalogContract.test.cjs +++ b/test/back/ql3ReleaseCatalogContract.test.cjs @@ -121,6 +121,10 @@ test('plans deterministic Local, Cluster and All OCI catalog entries', () => { `ghcr.io/qinglong-release/qinglong3-release-catalog:v${version}-${scope}`, ); assert.equal(plan.catalog.artifactType, ARTIFACT_TYPE); + assert.equal( + plan.publicationPolicy.recovery, + 'republish_deterministic_content_then_verify_digest', + ); assert.equal( plan.releaseSet.bytes, Buffer.byteLength(`${JSON.stringify(set)}\n`), diff --git a/test/back/ql3ReleaseEvidenceFixture.cjs b/test/back/ql3ReleaseEvidenceFixture.cjs index 505f215b..74c73483 100644 --- a/test/back/ql3ReleaseEvidenceFixture.cjs +++ b/test/back/ql3ReleaseEvidenceFixture.cjs @@ -2,7 +2,7 @@ const crypto = require('node:crypto'); -const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1'; +const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v2'; const FIXTURES = Object.freeze({ 'worker-management': 'qinglong/worker-credential-management-release-gate@v1', 'cloudnativepg-disaster-recovery': @@ -28,14 +28,13 @@ function privateReleaseEvidenceReceipt(release, evidenceKind) { })) : []; const unsigned = { - schemaVersion: 1, + schemaVersion: 2, schema: RECEIPT_SCHEMA, release: { ...release }, evidenceKind, evidence: { fixture: FIXTURES[evidenceKind], observedAt: '2026-08-18T00:00:00.000Z', - validatedAt: '2026-08-18T00:05:00.000Z', maximumAgeSeconds: 86_400, reportDigest: `sha256:${ evidenceKind === 'worker-management' ? '5' : '6' @@ -46,6 +45,8 @@ function privateReleaseEvidenceReceipt(release, evidenceKind) { verification: { sourceAwareAudit: true, privateEvidenceReplayed: true, + freshnessValidatedAtCreation: true, + durableValidationClockPublished: false, publicConsumerReplay: 'not_possible_without_private_reports', privateReportContentPublished: false, compatible: true,