Skip to content

Commit 1941d35

Browse files
Pigbibicodex
andauthored
fix: verify adaptive selection decision digest (#308)
Co-authored-by: Codex <noreply@openai.com>
1 parent 6cb014a commit 1941d35

3 files changed

Lines changed: 113 additions & 14 deletions

File tree

tests/strategy_switch_worker_validation.mjs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2104,7 +2104,7 @@ const adaptiveSelectionSourcePayload = {
21042104
decision: {
21052105
schema: "qsl.selection_decision.v1",
21062106
decision_id: "shadow-us-equity-combo-001",
2107-
created_at: controlNow,
2107+
created_at: "2026-08-29T00:00:00+00:00",
21082108
authority: "shadow_only",
21092109
no_order: true,
21102110
market_context: {
@@ -2134,6 +2134,13 @@ const adaptiveSelectionSourcePayload = {
21342134
},
21352135
errors: [],
21362136
};
2137+
adaptiveSelectionSourcePayload.decision.decision_digest = await __test.calculateAdaptiveSelectionDecisionDigest(
2138+
adaptiveSelectionSourcePayload.decision,
2139+
);
2140+
assert.equal(
2141+
adaptiveSelectionSourcePayload.decision.decision_digest,
2142+
"b5253cf3c2591b4ba0e7408fbdd3bb648b3813474ee6359d98dbdbb2556b8fe5",
2143+
);
21372144

21382145
const unauthorizedAdaptiveSelectionRead = await worker.fetch(
21392146
new Request("https://switch.example/api/adaptive-selection"),
@@ -2149,7 +2156,7 @@ const wrongAdaptiveSelectionToken = await worker.fetch(
21492156
adaptiveSelectionEnv,
21502157
);
21512158
assert.equal(wrongAdaptiveSelectionToken.status, 401);
2152-
assert.throws(
2159+
await assert.rejects(
21532160
() => __test.normalizeAdaptiveSelectionSourceSnapshot({
21542161
...adaptiveSelectionSourcePayload,
21552162
decision: {
@@ -2159,6 +2166,35 @@ assert.throws(
21592166
}),
21602167
/proposed_weight must remain zero/,
21612168
);
2169+
const legacyUndigestedDecision = { ...adaptiveSelectionSourcePayload.decision };
2170+
delete legacyUndigestedDecision.decision_digest;
2171+
await assert.rejects(
2172+
() => __test.normalizeAdaptiveSelectionSourceSnapshot({
2173+
...adaptiveSelectionSourcePayload,
2174+
decision: legacyUndigestedDecision,
2175+
}),
2176+
/has invalid fields/,
2177+
);
2178+
await assert.rejects(
2179+
() => __test.normalizeAdaptiveSelectionSourceSnapshot({
2180+
...adaptiveSelectionSourcePayload,
2181+
decision: {
2182+
...adaptiveSelectionSourcePayload.decision,
2183+
input_digest: "c".repeat(64),
2184+
},
2185+
}),
2186+
/decision_digest mismatch/,
2187+
);
2188+
await assert.rejects(
2189+
() => __test.normalizeAdaptiveSelectionSourceSnapshot({
2190+
...adaptiveSelectionSourcePayload,
2191+
decision: {
2192+
...adaptiveSelectionSourcePayload.decision,
2193+
decision_digest: "d".repeat(64),
2194+
},
2195+
}),
2196+
/decision_digest mismatch/,
2197+
);
21622198
const adaptiveSelectionSync = await worker.fetch(
21632199
new Request("https://switch.example/api/internal/sync-adaptive-selection-source", {
21642200
method: "POST",

web/strategy-switch-console/README.zh-CN.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,16 @@ GET /api/adaptive-selection
210210
```
211211

212212
写入的外层契约是 `qsl.adaptive_selection_source_snapshot.v1`,其中的 decision 必须仍是
213-
`authority=shadow_only``no_order=true`,且每个 `proposed_weight=0`。Worker 会拒绝非零
214-
权重、非 Shadow authority、缺少完整输入摘要或格式不安全的字段。读取接口要求已登录
215-
allowlist;默认 36 小时后快照标为 `stale`,不会被当作当前结论。
213+
`authority=shadow_only``no_order=true`,且每个 `proposed_weight=0``qsl.selection_decision.v1`
214+
还必须携带 QPK `canonical_sha256` 计算的 `decision_digest`:Worker 会重新计算它,并因此将
215+
`input_digest` 与全部展示字段一并绑定。Worker 会拒绝摘要不匹配、非零权重、非 Shadow
216+
authority、非 no-order、缺少完整摘要或格式不安全的字段。读取接口要求已登录 allowlist;默认
217+
36 小时后快照标为 `stale`,不会被当作当前结论。
218+
219+
兼容策略是**显式拒绝旧的无 `decision_digest` 投影**:这类 pre-digest 记录不能证明它们的
220+
`input_digest` 与显示内容仍然一致,因此不会被当作 `v1` 的安全等价物,也不会被自动补摘要或
221+
迁移。来源必须使用带摘要的 QPK `qsl.selection_decision.v1` 重新导出;重导出不会创建
222+
Shadow、修改 runtime 或产生订单。
216223

217224
`ADAPTIVE_SELECTION_SYNC_TOKEN` 必须专用,不能复用 OAuth、策略切换、控制面、执行证据、
218225
顾投、券商或账户凭据。顾投研究与 M1 的卡片也必须保持分开:顾投只能提出研究假设,不能

web/strategy-switch-console/worker.js

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2170,7 +2170,7 @@ async function syncAdaptiveSelectionSourceResponse(request, env) {
21702170

21712171
let source;
21722172
try {
2173-
source = normalizeAdaptiveSelectionSourceSnapshot(raw, "adaptive selection source snapshot");
2173+
source = await normalizeAdaptiveSelectionSourceSnapshot(raw, "adaptive selection source snapshot");
21742174
} catch (error) {
21752175
return json({ ok: false, error: error.message || "invalid adaptive selection payload" }, 400);
21762176
}
@@ -2266,7 +2266,7 @@ async function readAdaptiveSelectionSources(env) {
22662266
try {
22672267
const stored = await readConfigJson(env, key);
22682268
if (!stored) continue;
2269-
sources.push(normalizeAdaptiveSelectionSourceSnapshot(stored, key));
2269+
sources.push(await normalizeAdaptiveSelectionSourceSnapshot(stored, key));
22702270
} catch {
22712271
sources.push(emptyAdaptiveSelectionSourceSnapshot("adaptive_selection_source_invalid"));
22722272
}
@@ -2859,7 +2859,7 @@ function emptyControlPlaneSourceSnapshot(errorCode) {
28592859
};
28602860
}
28612861

2862-
function normalizeAdaptiveSelectionSourceSnapshot(payload, fieldName = "adaptive selection source snapshot") {
2862+
async function normalizeAdaptiveSelectionSourceSnapshot(payload, fieldName = "adaptive selection source snapshot") {
28632863
const source = assertExactFields(payload, [
28642864
"schema_version", "source_id", "generated_at", "computed_at", "data_status", "decision", "errors",
28652865
], fieldName);
@@ -2871,7 +2871,7 @@ function normalizeAdaptiveSelectionSourceSnapshot(payload, fieldName = "adaptive
28712871
const computedAt = normalizeStrategyHealthTimestamp(source.computed_at, `${fieldName}.computed_at`, true);
28722872
const decision = source.decision === null
28732873
? null
2874-
: normalizeAdaptiveSelectionDecision(source.decision, `${fieldName}.decision`);
2874+
: await normalizeAdaptiveSelectionDecision(source.decision, `${fieldName}.decision`);
28752875
if (dataStatus === "unavailable" && decision !== null) {
28762876
throw new Error(`${fieldName}.decision must be null when unavailable`);
28772877
}
@@ -2889,10 +2889,10 @@ function normalizeAdaptiveSelectionSourceSnapshot(payload, fieldName = "adaptive
28892889
};
28902890
}
28912891

2892-
function normalizeAdaptiveSelectionDecision(payload, fieldName) {
2892+
async function normalizeAdaptiveSelectionDecision(payload, fieldName) {
28932893
const value = assertExactFields(payload, [
28942894
"schema", "decision_id", "created_at", "authority", "no_order", "market_context", "policy_id",
2895-
"recommended_strategy_profile", "recommended_platform_id", "candidates", "input_digest",
2895+
"recommended_strategy_profile", "recommended_platform_id", "candidates", "input_digest", "decision_digest",
28962896
], fieldName);
28972897
if (value.schema !== ADAPTIVE_SELECTION_DECISION_SCHEMA_VERSION) {
28982898
throw new Error(`${fieldName}.schema is unsupported`);
@@ -2926,7 +2926,7 @@ function normalizeAdaptiveSelectionDecision(payload, fieldName) {
29262926
if (recommendedStrategy && (!recommendedCandidate || recommendedCandidate.selected_platform_id !== recommendedPlatform)) {
29272927
throw new Error(`${fieldName}.recommended candidate is not accepted`);
29282928
}
2929-
return {
2929+
const normalized = {
29302930
schema: ADAPTIVE_SELECTION_DECISION_SCHEMA_VERSION,
29312931
decision_id: normalizeControlPlaneIdentifier(value.decision_id, `${fieldName}.decision_id`, false),
29322932
created_at: normalizeStrategyHealthTimestamp(value.created_at, `${fieldName}.created_at`),
@@ -2938,7 +2938,16 @@ function normalizeAdaptiveSelectionDecision(payload, fieldName) {
29382938
recommended_platform_id: recommendedPlatform,
29392939
candidates,
29402940
input_digest: normalizeAdaptiveSelectionDigest(value.input_digest, `${fieldName}.input_digest`),
2941+
decision_digest: normalizeAdaptiveSelectionDigest(value.decision_digest, `${fieldName}.decision_digest`),
29412942
};
2943+
// `input_digest` cannot be reconstructed from the display projection alone:
2944+
// QPK calculates it from the private, immutable selection input. The QPK
2945+
// decision digest binds that exact input digest to every decision field that
2946+
// is displayed here, so a changed input digest cannot be silently accepted.
2947+
if (normalized.decision_digest !== await calculateAdaptiveSelectionDecisionDigest(normalized)) {
2948+
throw new Error(`${fieldName}.decision_digest mismatch`);
2949+
}
2950+
return normalized;
29422951
}
29432952

29442953
function normalizeAdaptiveSelectionMarketContext(value, fieldName) {
@@ -2987,9 +2996,11 @@ function normalizeAdaptiveSelectionCandidate(value, fieldName) {
29872996
const item = assertExactFields(value, [
29882997
"strategy_profile", "release_digest", "selected_platform_id", "score", "risk_multiplier", "accepted", "reasons", "proposed_weight",
29892998
], fieldName);
2990-
const score = Number(item.score);
2999+
const score = item.score === null ? null : Number(item.score);
29913000
const riskMultiplier = Number(item.risk_multiplier);
2992-
if (!Number.isFinite(score) || Math.abs(score) > 1_000_000) throw new Error(`${fieldName}.score is invalid`);
3001+
if (score !== null && (!Number.isFinite(score) || Math.abs(score) > 1_000_000)) {
3002+
throw new Error(`${fieldName}.score is invalid`);
3003+
}
29933004
if (!Number.isFinite(riskMultiplier) || riskMultiplier < 0 || riskMultiplier > 1) {
29943005
throw new Error(`${fieldName}.risk_multiplier is invalid`);
29953006
}
@@ -3027,6 +3038,50 @@ function normalizeAdaptiveSelectionDigest(value, fieldName) {
30273038
return text;
30283039
}
30293040

3041+
function isAdaptiveSelectionFloatPath(path) {
3042+
if (path[0] === "market_context") {
3043+
return path[1] === "regime_confidence" || path[1] === "factors";
3044+
}
3045+
return path[0] === "candidates"
3046+
&& ["score", "risk_multiplier", "proposed_weight"].includes(path[2]);
3047+
}
3048+
3049+
function canonicalAdaptiveSelectionNumber(value, forceFloat) {
3050+
if (!Number.isFinite(value)) throw new Error("adaptive selection decision must use finite JSON values");
3051+
if (Object.is(value, -0)) return forceFloat ? "-0.0" : "0";
3052+
let text = String(value);
3053+
const exponent = text.match(/^(.*)e([+-]?)(\d+)$/i);
3054+
if (exponent) {
3055+
const [, mantissa, sign, rawExponent] = exponent;
3056+
// Python's json.dumps (used by QPK canonical_sha256) pads one-digit
3057+
// negative exponents, while V8's Number#toString does not.
3058+
text = `${mantissa}e${sign || "+"}${sign === "-" ? rawExponent.padStart(2, "0") : rawExponent}`;
3059+
}
3060+
if (forceFloat && !/[.eE]/.test(text)) return `${text}.0`;
3061+
return text;
3062+
}
3063+
3064+
function canonicalAdaptiveSelectionDecisionJson(value, path = []) {
3065+
if (value === null) return "null";
3066+
if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
3067+
if (typeof value === "number") return canonicalAdaptiveSelectionNumber(value, isAdaptiveSelectionFloatPath(path));
3068+
if (Array.isArray(value)) {
3069+
return `[${value.map((item) => canonicalAdaptiveSelectionDecisionJson(item, [...path, "*"])).join(",")}]`;
3070+
}
3071+
if (!value || typeof value !== "object") throw new Error("adaptive selection decision must use JSON values");
3072+
return `{${Object.keys(value).sort().map((key) => (
3073+
`${JSON.stringify(key)}:${canonicalAdaptiveSelectionDecisionJson(value[key], [...path, key])}`
3074+
)).join(",")}}`;
3075+
}
3076+
3077+
async function calculateAdaptiveSelectionDecisionDigest(payload) {
3078+
const material = { ...payload };
3079+
delete material.decision_digest;
3080+
const raw = new TextEncoder().encode(canonicalAdaptiveSelectionDecisionJson(material));
3081+
const digest = await crypto.subtle.digest("SHA-256", raw);
3082+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
3083+
}
3084+
30303085
function normalizeAdaptiveSelectionSummary(selections) {
30313086
const entries = Array.isArray(selections) ? selections : [];
30323087
const decisions = entries.map((item) => item.decision).filter(Boolean);
@@ -5253,6 +5308,7 @@ export const __test = {
52535308
normalizeControlPlaneSourceSnapshot,
52545309
emptyControlPlanePayload,
52555310
normalizeAdaptiveSelectionSourceSnapshot,
5311+
calculateAdaptiveSelectionDecisionDigest,
52565312
emptyAdaptiveSelectionPayload,
52575313
normalizeExecutionEvidenceSourceSnapshot,
52585314
emptyExecutionEvidencePayload,

0 commit comments

Comments
 (0)