Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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"),
Expand All @@ -2149,7 +2156,7 @@ const wrongAdaptiveSelectionToken = await worker.fetch(
adaptiveSelectionEnv,
);
assert.equal(wrongAdaptiveSelectionToken.status, 401);
assert.throws(
await assert.rejects(
() => __test.normalizeAdaptiveSelectionSourceSnapshot({
...adaptiveSelectionSourcePayload,
decision: {
Expand All @@ -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",
Expand Down
13 changes: 10 additions & 3 deletions web/strategy-switch-console/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的卡片也必须保持分开:顾投只能提出研究假设,不能
Expand Down
74 changes: 65 additions & 9 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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"));
}
Expand Down Expand Up @@ -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);
Expand All @@ -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`);
}
Expand All @@ -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`);
Expand Down Expand Up @@ -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`),
Expand All @@ -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) {
Expand Down Expand Up @@ -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`);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -5253,6 +5308,7 @@ export const __test = {
normalizeControlPlaneSourceSnapshot,
emptyControlPlanePayload,
normalizeAdaptiveSelectionSourceSnapshot,
calculateAdaptiveSelectionDecisionDigest,
emptyAdaptiveSelectionPayload,
normalizeExecutionEvidenceSourceSnapshot,
emptyExecutionEvidencePayload,
Expand Down