From 3ccc27e5090c8b649b8360973332705e85151794 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:57:49 +0800 Subject: [PATCH] fix: verify adaptive selection decision digest Co-Authored-By: Codex --- tests/strategy_switch_worker_validation.mjs | 40 ++++++++++- web/strategy-switch-console/README.zh-CN.md | 13 +++- web/strategy-switch-console/worker.js | 74 ++++++++++++++++++--- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index bad4359..1a4ec92 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -2104,7 +2104,7 @@ const adaptiveSelectionSourcePayload = { decision: { schema: "qsl.selection_decision.v1", decision_id: "shadow-us-equity-combo-001", - created_at: controlNow, + created_at: "2026-08-29T00:00:00+00:00", authority: "shadow_only", no_order: true, market_context: { @@ -2134,6 +2134,13 @@ const adaptiveSelectionSourcePayload = { }, errors: [], }; +adaptiveSelectionSourcePayload.decision.decision_digest = await __test.calculateAdaptiveSelectionDecisionDigest( + adaptiveSelectionSourcePayload.decision, +); +assert.equal( + adaptiveSelectionSourcePayload.decision.decision_digest, + "b5253cf3c2591b4ba0e7408fbdd3bb648b3813474ee6359d98dbdbb2556b8fe5", +); const unauthorizedAdaptiveSelectionRead = await worker.fetch( new Request("https://switch.example/api/adaptive-selection"), @@ -2149,7 +2156,7 @@ const wrongAdaptiveSelectionToken = await worker.fetch( adaptiveSelectionEnv, ); assert.equal(wrongAdaptiveSelectionToken.status, 401); -assert.throws( +await assert.rejects( () => __test.normalizeAdaptiveSelectionSourceSnapshot({ ...adaptiveSelectionSourcePayload, decision: { @@ -2159,6 +2166,35 @@ assert.throws( }), /proposed_weight must remain zero/, ); +const legacyUndigestedDecision = { ...adaptiveSelectionSourcePayload.decision }; +delete legacyUndigestedDecision.decision_digest; +await assert.rejects( + () => __test.normalizeAdaptiveSelectionSourceSnapshot({ + ...adaptiveSelectionSourcePayload, + decision: legacyUndigestedDecision, + }), + /has invalid fields/, +); +await assert.rejects( + () => __test.normalizeAdaptiveSelectionSourceSnapshot({ + ...adaptiveSelectionSourcePayload, + decision: { + ...adaptiveSelectionSourcePayload.decision, + input_digest: "c".repeat(64), + }, + }), + /decision_digest mismatch/, +); +await assert.rejects( + () => __test.normalizeAdaptiveSelectionSourceSnapshot({ + ...adaptiveSelectionSourcePayload, + decision: { + ...adaptiveSelectionSourcePayload.decision, + decision_digest: "d".repeat(64), + }, + }), + /decision_digest mismatch/, +); const adaptiveSelectionSync = await worker.fetch( new Request("https://switch.example/api/internal/sync-adaptive-selection-source", { method: "POST", diff --git a/web/strategy-switch-console/README.zh-CN.md b/web/strategy-switch-console/README.zh-CN.md index 4ae1627..a3238cb 100644 --- a/web/strategy-switch-console/README.zh-CN.md +++ b/web/strategy-switch-console/README.zh-CN.md @@ -210,9 +210,16 @@ GET /api/adaptive-selection ``` 写入的外层契约是 `qsl.adaptive_selection_source_snapshot.v1`,其中的 decision 必须仍是 -`authority=shadow_only`、`no_order=true`,且每个 `proposed_weight=0`。Worker 会拒绝非零 -权重、非 Shadow authority、缺少完整输入摘要或格式不安全的字段。读取接口要求已登录 -allowlist;默认 36 小时后快照标为 `stale`,不会被当作当前结论。 +`authority=shadow_only`、`no_order=true`,且每个 `proposed_weight=0`。`qsl.selection_decision.v1` +还必须携带 QPK `canonical_sha256` 计算的 `decision_digest`:Worker 会重新计算它,并因此将 +`input_digest` 与全部展示字段一并绑定。Worker 会拒绝摘要不匹配、非零权重、非 Shadow +authority、非 no-order、缺少完整摘要或格式不安全的字段。读取接口要求已登录 allowlist;默认 +36 小时后快照标为 `stale`,不会被当作当前结论。 + +兼容策略是**显式拒绝旧的无 `decision_digest` 投影**:这类 pre-digest 记录不能证明它们的 +`input_digest` 与显示内容仍然一致,因此不会被当作 `v1` 的安全等价物,也不会被自动补摘要或 +迁移。来源必须使用带摘要的 QPK `qsl.selection_decision.v1` 重新导出;重导出不会创建 +Shadow、修改 runtime 或产生订单。 `ADAPTIVE_SELECTION_SYNC_TOKEN` 必须专用,不能复用 OAuth、策略切换、控制面、执行证据、 顾投、券商或账户凭据。顾投研究与 M1 的卡片也必须保持分开:顾投只能提出研究假设,不能 diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 1442327..10c541e 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -2170,7 +2170,7 @@ async function syncAdaptiveSelectionSourceResponse(request, env) { let source; try { - source = normalizeAdaptiveSelectionSourceSnapshot(raw, "adaptive selection source snapshot"); + source = await normalizeAdaptiveSelectionSourceSnapshot(raw, "adaptive selection source snapshot"); } catch (error) { return json({ ok: false, error: error.message || "invalid adaptive selection payload" }, 400); } @@ -2266,7 +2266,7 @@ async function readAdaptiveSelectionSources(env) { try { const stored = await readConfigJson(env, key); if (!stored) continue; - sources.push(normalizeAdaptiveSelectionSourceSnapshot(stored, key)); + sources.push(await normalizeAdaptiveSelectionSourceSnapshot(stored, key)); } catch { sources.push(emptyAdaptiveSelectionSourceSnapshot("adaptive_selection_source_invalid")); } @@ -2859,7 +2859,7 @@ function emptyControlPlaneSourceSnapshot(errorCode) { }; } -function normalizeAdaptiveSelectionSourceSnapshot(payload, fieldName = "adaptive selection source snapshot") { +async function normalizeAdaptiveSelectionSourceSnapshot(payload, fieldName = "adaptive selection source snapshot") { const source = assertExactFields(payload, [ "schema_version", "source_id", "generated_at", "computed_at", "data_status", "decision", "errors", ], fieldName); @@ -2871,7 +2871,7 @@ function normalizeAdaptiveSelectionSourceSnapshot(payload, fieldName = "adaptive const computedAt = normalizeStrategyHealthTimestamp(source.computed_at, `${fieldName}.computed_at`, true); const decision = source.decision === null ? null - : normalizeAdaptiveSelectionDecision(source.decision, `${fieldName}.decision`); + : await normalizeAdaptiveSelectionDecision(source.decision, `${fieldName}.decision`); if (dataStatus === "unavailable" && decision !== null) { throw new Error(`${fieldName}.decision must be null when unavailable`); } @@ -2889,10 +2889,10 @@ function normalizeAdaptiveSelectionSourceSnapshot(payload, fieldName = "adaptive }; } -function normalizeAdaptiveSelectionDecision(payload, fieldName) { +async function normalizeAdaptiveSelectionDecision(payload, fieldName) { const value = assertExactFields(payload, [ "schema", "decision_id", "created_at", "authority", "no_order", "market_context", "policy_id", - "recommended_strategy_profile", "recommended_platform_id", "candidates", "input_digest", + "recommended_strategy_profile", "recommended_platform_id", "candidates", "input_digest", "decision_digest", ], fieldName); if (value.schema !== ADAPTIVE_SELECTION_DECISION_SCHEMA_VERSION) { throw new Error(`${fieldName}.schema is unsupported`); @@ -2926,7 +2926,7 @@ function normalizeAdaptiveSelectionDecision(payload, fieldName) { if (recommendedStrategy && (!recommendedCandidate || recommendedCandidate.selected_platform_id !== recommendedPlatform)) { throw new Error(`${fieldName}.recommended candidate is not accepted`); } - return { + const normalized = { schema: ADAPTIVE_SELECTION_DECISION_SCHEMA_VERSION, decision_id: normalizeControlPlaneIdentifier(value.decision_id, `${fieldName}.decision_id`, false), created_at: normalizeStrategyHealthTimestamp(value.created_at, `${fieldName}.created_at`), @@ -2938,7 +2938,16 @@ function normalizeAdaptiveSelectionDecision(payload, fieldName) { recommended_platform_id: recommendedPlatform, candidates, input_digest: normalizeAdaptiveSelectionDigest(value.input_digest, `${fieldName}.input_digest`), + decision_digest: normalizeAdaptiveSelectionDigest(value.decision_digest, `${fieldName}.decision_digest`), }; + // `input_digest` cannot be reconstructed from the display projection alone: + // QPK calculates it from the private, immutable selection input. The QPK + // decision digest binds that exact input digest to every decision field that + // is displayed here, so a changed input digest cannot be silently accepted. + if (normalized.decision_digest !== await calculateAdaptiveSelectionDecisionDigest(normalized)) { + throw new Error(`${fieldName}.decision_digest mismatch`); + } + return normalized; } function normalizeAdaptiveSelectionMarketContext(value, fieldName) { @@ -2987,9 +2996,11 @@ function normalizeAdaptiveSelectionCandidate(value, fieldName) { const item = assertExactFields(value, [ "strategy_profile", "release_digest", "selected_platform_id", "score", "risk_multiplier", "accepted", "reasons", "proposed_weight", ], fieldName); - const score = Number(item.score); + const score = item.score === null ? null : Number(item.score); const riskMultiplier = Number(item.risk_multiplier); - if (!Number.isFinite(score) || Math.abs(score) > 1_000_000) throw new Error(`${fieldName}.score is invalid`); + if (score !== null && (!Number.isFinite(score) || Math.abs(score) > 1_000_000)) { + throw new Error(`${fieldName}.score is invalid`); + } if (!Number.isFinite(riskMultiplier) || riskMultiplier < 0 || riskMultiplier > 1) { throw new Error(`${fieldName}.risk_multiplier is invalid`); } @@ -3027,6 +3038,50 @@ function normalizeAdaptiveSelectionDigest(value, fieldName) { return text; } +function isAdaptiveSelectionFloatPath(path) { + if (path[0] === "market_context") { + return path[1] === "regime_confidence" || path[1] === "factors"; + } + return path[0] === "candidates" + && ["score", "risk_multiplier", "proposed_weight"].includes(path[2]); +} + +function canonicalAdaptiveSelectionNumber(value, forceFloat) { + if (!Number.isFinite(value)) throw new Error("adaptive selection decision must use finite JSON values"); + if (Object.is(value, -0)) return forceFloat ? "-0.0" : "0"; + let text = String(value); + const exponent = text.match(/^(.*)e([+-]?)(\d+)$/i); + if (exponent) { + const [, mantissa, sign, rawExponent] = exponent; + // Python's json.dumps (used by QPK canonical_sha256) pads one-digit + // negative exponents, while V8's Number#toString does not. + text = `${mantissa}e${sign || "+"}${sign === "-" ? rawExponent.padStart(2, "0") : rawExponent}`; + } + if (forceFloat && !/[.eE]/.test(text)) return `${text}.0`; + return text; +} + +function canonicalAdaptiveSelectionDecisionJson(value, path = []) { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") return canonicalAdaptiveSelectionNumber(value, isAdaptiveSelectionFloatPath(path)); + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalAdaptiveSelectionDecisionJson(item, [...path, "*"])).join(",")}]`; + } + if (!value || typeof value !== "object") throw new Error("adaptive selection decision must use JSON values"); + return `{${Object.keys(value).sort().map((key) => ( + `${JSON.stringify(key)}:${canonicalAdaptiveSelectionDecisionJson(value[key], [...path, key])}` + )).join(",")}}`; +} + +async function calculateAdaptiveSelectionDecisionDigest(payload) { + const material = { ...payload }; + delete material.decision_digest; + const raw = new TextEncoder().encode(canonicalAdaptiveSelectionDecisionJson(material)); + const digest = await crypto.subtle.digest("SHA-256", raw); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + function normalizeAdaptiveSelectionSummary(selections) { const entries = Array.isArray(selections) ? selections : []; const decisions = entries.map((item) => item.decision).filter(Boolean); @@ -5253,6 +5308,7 @@ export const __test = { normalizeControlPlaneSourceSnapshot, emptyControlPlanePayload, normalizeAdaptiveSelectionSourceSnapshot, + calculateAdaptiveSelectionDecisionDigest, emptyAdaptiveSelectionPayload, normalizeExecutionEvidenceSourceSnapshot, emptyExecutionEvidencePayload,