@@ -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
29442953function 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 && ! / [ . e E ] / . 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+
30303085function 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