From 3b06f73f164b8130d667b3bb0781b64561440fd5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:01:30 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(console):=20name=20the=20refusal=20code?= =?UTF-8?q?=20when=20account=20facts=20are=20rejected=20A=20producer=20tha?= =?UTF-8?q?t=20is=20refused=20a=20push=20keeps=20only=20the=20HTTP=20statu?= =?UTF-8?q?s=20=E2=80=94=20the=20IBKR=20publisher=20forbids=20reading=20th?= =?UTF-8?q?e=20response=20body=20=E2=80=94=20so=20the=202026-10-31=20sched?= =?UTF-8?q?uled=20run=20left=20three=20409s=20that=20no=20log=20could=20at?= =?UTF-8?q?tribute=20to=20a=20gate.=20The=20receiver=20already=20knows=20t?= =?UTF-8?q?he=20code=20it=20returned,=20so=20emit=20it,=20with=20the=20end?= =?UTF-8?q?point=20that=20refused,=20as=20one=20bounded=20log=20event.=20F?= =?UTF-8?q?ree-form=20failure=20text=20is=20classified=20to=20"unknown"=20?= =?UTF-8?q?so=20payload=20values,=20identities=20and=20credentials=20canno?= =?UTF-8?q?t=20reach=20the=20log=20stream,=20and=20the=20response=20body?= =?UTF-8?q?=20and=20status=20are=20exactly=20what=20this=20helper=20return?= =?UTF-8?q?ed=20before.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nt_facts_history_currencies_validation.mjs | 40 +++++++++++++++++++ web/strategy-switch-console/worker.js | 33 +++++++++------ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/tests/account_facts_history_currencies_validation.mjs b/tests/account_facts_history_currencies_validation.mjs index 3f3767fa..e184cbec 100644 --- a/tests/account_facts_history_currencies_validation.mjs +++ b/tests/account_facts_history_currencies_validation.mjs @@ -94,6 +94,11 @@ console.log('account facts history currency discovery: synthetic qualified linea // Exercise the actual Worker wiring: its binding loader must not swallow a KV // read failure before the history use case can distinguish it from no binding. const { default: worker, __test } = await import('../web/strategy-switch-console/worker.js'); +// Sync producers keep only the HTTP status, so each refusal must also name its +// code in one bounded log event. Capture the events instead of printing them. +const rejections=[]; +const realWarn=console.warn; +console.warn=(event,...rest)=>{ if(event==='account_facts_refused') rejections.push(rest[0]); else realWarn.apply(console,[event,...rest]); }; let kvFailure=null, missingBinding=false, storedHistory={ok:true,days:[]}, storedStatus=200; const workerEnv={ ACCOUNT_FACTS_READ_MODEL_ENABLED:'true', @@ -151,4 +156,39 @@ try { missingBinding=true; assert.equal((await requestHistory(legacyUrl)).status,200,'a successful missing binding read keeps its legacy empty state'); } finally {globalThis.fetch=originalFetch;} +// One event per refusal, the code identical to the one the client receives, and no +// field beyond the fixed endpoint, the status and the classified code. +assert.ok(rejections.length>=3,'every rejected account-facts read above is observable'); +assert.deepEqual(rejections.at(-1),{endpoint:'read',http_status:409,error_category:'account_facts_stored_invalid'}, + 'the logged code is exactly the code the producer receives'); +for(const event of rejections){ + assert.deepEqual(Object.keys(event).sort(),['endpoint','error_category','http_status'],'the event carries no other field'); + assert.equal(event.endpoint,'read','reads keep the default endpoint literal'); + assert.match(event.error_category,/^[a-z][a-z0-9_]{2,63}$/); + assert.ok(Number.isInteger(event.http_status)&&event.http_status>=400&&event.http_status<=599); +} +// A free-form failure keeps its previous response body but is classified, never copied. +const unclassified=await __test.accountFactsErrorResponse(new Error('net_assets 98765.432101 rejected')); +assert.equal(unclassified.status,400,'an unclassified failure stays a 400'); +assert.equal((await unclassified.json()).error,'net_assets 98765.432101 rejected','the response is unchanged'); +assert.equal(rejections.at(-1).error_category,'unknown','free-form text is classified, never copied'); +const syncRefusal=await __test.accountFactsErrorResponse( + Object.assign(new Error('period_return_conflict'),{status:409}),'period_return_sync'); +assert.equal(syncRefusal.status,409); +assert.equal((await syncRefusal.json()).error,'period_return_conflict'); +assert.deepEqual(rejections.at(-1),{endpoint:'period_return_sync',http_status:409,error_category:'period_return_conflict'}, + 'a sync refusal names the endpoint the producer posted to'); +// The history-sync use case lives in application/account_facts/sync.js and calls its +// injected error mapper with no endpoint, so the worker injection site must pre-tag it; +// otherwise a refused history push would be logged as a read. +const historySync=await worker.fetch(new Request('https://switch.example/api/account-facts/history/sync',{ + method:'POST',headers:{'Content-Type':'application/json'},body:'{}', +}),workerEnv); +assert.equal(historySync.status,500,'this synthetic env configures no sync token'); +assert.deepEqual(await historySync.json(),{ok:false,error:'account_facts_sync_token_not_configured'}); +assert.deepEqual(rejections.at(-1), + {endpoint:'history_sync',http_status:500,error_category:'account_facts_sync_token_not_configured'}, + 'a refused history push is attributed to the sync endpoint, not to a read'); +assert.ok(!JSON.stringify(rejections).includes('98765'),'no payload value reaches the log stream'); +console.warn=realWarn; console.log('account facts history Worker wiring: synthetic KV/DO failures and empty states passed'); diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index bea70133..006a3c4c 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -11519,14 +11519,22 @@ function accountFactsDisabledResponse() { return json({ ok: false, error: "account_facts_disabled" }, 404); } -function accountFactsErrorResponse(error) { - if (error instanceof AccountFactsError) { - return json({ ok: false, error: error.code }, error.status || 400); - } - if (error instanceof HttpError) { - return json({ ok: false, error: error.message }, error.status || 400); - } - return json({ ok: false, error: error?.message || "invalid_account_facts_history" }, error?.status || 400); +// Sync producers keep only the HTTP status, so a refused push is undiagnosable +// from their logs. Publish the refusal code, tagged with a fixed endpoint literal +// from the caller; free-form exception text is classified to "unknown" so payload +// content, identities and credentials cannot enter the log stream. The response +// body and status are exactly what this helper already returned. +function accountFactsErrorResponse(error, endpoint = "read") { + const code = error instanceof AccountFactsError ? error.code + : error instanceof HttpError ? error.message + : error?.message || "invalid_account_facts_history"; + const status = error?.status || 400; + console.warn("account_facts_refused", { + endpoint, + http_status: status, + error_category: typeof code === "string" && /^[a-z][a-z0-9_]{2,63}$/.test(code) ? code : "unknown", + }); + return json({ ok: false, error: code }, status); } function requireCycleHealthProvisionToken(request, env) { @@ -11762,7 +11770,7 @@ async function syncAccountExternalFlowResponse(request, env) { record_count: new Set(flow.windows.flatMap(w => w.records.map(r => r.source_ref))).size }); } catch (error) { if (error instanceof AccountFactsError || error instanceof HttpError || error?.code?.startsWith("external_flow") - || error?.code === "invalid_external_flow") return accountFactsErrorResponse(error); + || error?.code === "invalid_external_flow") return accountFactsErrorResponse(error, "external_flow_sync"); return json({ ok: false, error: "account_external_flow_unavailable" }, 503); } } @@ -11787,7 +11795,7 @@ async function syncAccountExecutionsResponse(request, env) { account_key: binding.account_key, period: payload.period, record_count: payload.records.length }); } catch (error) { if (error instanceof AccountFactsError || error instanceof HttpError || error?.code?.startsWith("execution_") - || error?.code === "invalid_broker_executions") return accountFactsErrorResponse(error); + || error?.code === "invalid_broker_executions") return accountFactsErrorResponse(error, "executions_sync"); return json({ ok: false, error: "account_executions_unavailable" }, 503); } } @@ -11811,7 +11819,7 @@ async function syncAccountPeriodReturnResponse(request, env) { return json({ ok: true, stored: true, unchanged: stored.unchanged === true, account_key: binding.account_key, period: payload.period, currency: payload.currency, method: payload.method }); } catch (error) { - if (error instanceof AccountFactsError || error instanceof HttpError) return accountFactsErrorResponse(error); + if (error instanceof AccountFactsError || error instanceof HttpError) return accountFactsErrorResponse(error, "period_return_sync"); return json({ ok: false, error: "account_period_return_unavailable" }, 503); } } @@ -11826,7 +11834,7 @@ async function syncAccountFactsResponse(request, env, options) { runtimeInstanceCommand, readBoundedJson, requireDedicatedAccountFactsSyncToken, - accountFactsErrorResponse, + accountFactsErrorResponse: (error) => accountFactsErrorResponse(error, "history_sync"), }, options); } @@ -17721,6 +17729,7 @@ export const __test = { resolveTrustedAccountFactsBinding, decideAccountFactsPut, accountFactsOptionMatchesBinding, + accountFactsErrorResponse, canonicalizeAccountFactsHistory, ux1JobUnknownAfterMs: UX1_JOB_UNKNOWN_AFTER_MS, ux1CalculatorDeadlineMs: UX1_CALCULATOR_DEADLINE_MS, From c851c9ed86ce915280b458a14cb98cbc73a0e638 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:10:53 +0800 Subject: [PATCH 2/3] test(account-facts): stop the rotation fixture ageing out of the freshness window The cross-day rotation fixture stamped yesterday at 10:00Z, so after 22:00Z the live sync endpoint refused it with account_facts_observation_window (400) before the old-source qualification gate could answer 409. Every branch that ran CI in that two-hour window failed identically, including branches untouched by this change. Anchor the fixture to one hour before the same time yesterday instead: still a different calendar day, always inside the 36h window. --- tests/account_facts_validation.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/account_facts_validation.mjs b/tests/account_facts_validation.mjs index ff33cce9..6c52e3c3 100644 --- a/tests/account_facts_validation.mjs +++ b/tests/account_facts_validation.mjs @@ -1515,9 +1515,11 @@ const afterLbRefresh = await (await get(sessionHeaders, env)).json(); assert.equal(afterLbRefresh.accounts.find((item) => item.platform === "longbridge" && item.account_key === account.key).data_status, "fresh"); // Cross-day binding rotation: old daily rows stay stored but are excluded from -// the current account history for both platforms. +// the current account history for both platforms. The hour must stay recent: the +// live sync endpoint refuses any observation older than its 36h freshness window, +// so a fixed hour made the run time-dependent rather than the rotation. const previousDayStart = new Date(Date.now() - 86400000); -previousDayStart.setUTCHours(10, 0, 0, 0); +previousDayStart.setUTCHours((previousDayStart.getUTCHours() + 23) % 24, 0, 0, 0); const previousDayStartedAt = previousDayStart.toISOString().replace(/\.\d{3}Z$/, "Z"); const previousDayFinishedAt = new Date(previousDayStart.getTime() + 60000).toISOString().replace(/\.\d{3}Z$/, "Z"); const rotationLb = { From 14cb41976eb4c4e05ae83102349ee09e7d9b7a2a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:34:59 +0800 Subject: [PATCH 3/3] fix(console): label current and historical sync refusals apart One adapter serves both /api/account-facts/sync and /api/account-facts/history/sync, so a hard-coded literal logged a refused current snapshot as a history append. Pick the label from the route's historical flag and assert both endpoints in one table. --- ...nt_facts_history_currencies_validation.mjs | 24 ++++++++++--------- web/strategy-switch-console/worker.js | 3 ++- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/account_facts_history_currencies_validation.mjs b/tests/account_facts_history_currencies_validation.mjs index e184cbec..c5bee1a9 100644 --- a/tests/account_facts_history_currencies_validation.mjs +++ b/tests/account_facts_history_currencies_validation.mjs @@ -178,17 +178,19 @@ assert.equal(syncRefusal.status,409); assert.equal((await syncRefusal.json()).error,'period_return_conflict'); assert.deepEqual(rejections.at(-1),{endpoint:'period_return_sync',http_status:409,error_category:'period_return_conflict'}, 'a sync refusal names the endpoint the producer posted to'); -// The history-sync use case lives in application/account_facts/sync.js and calls its -// injected error mapper with no endpoint, so the worker injection site must pre-tag it; -// otherwise a refused history push would be logged as a read. -const historySync=await worker.fetch(new Request('https://switch.example/api/account-facts/history/sync',{ - method:'POST',headers:{'Content-Type':'application/json'},body:'{}', -}),workerEnv); -assert.equal(historySync.status,500,'this synthetic env configures no sync token'); -assert.deepEqual(await historySync.json(),{ok:false,error:'account_facts_sync_token_not_configured'}); -assert.deepEqual(rejections.at(-1), - {endpoint:'history_sync',http_status:500,error_category:'account_facts_sync_token_not_configured'}, - 'a refused history push is attributed to the sync endpoint, not to a read'); +// Both sync routes live in application/account_facts/sync.js and call their injected +// error mapper with no endpoint, so the worker injection site must pick the label from +// the route; otherwise a refused current snapshot would be logged as a history append. +for(const [path,endpoint] of [['/api/account-facts/sync','current_sync'],['/api/account-facts/history/sync','history_sync']]){ + const refused=await worker.fetch(new Request(`https://switch.example${path}`,{ + method:'POST',headers:{'Content-Type':'application/json'},body:'{}', + }),workerEnv); + assert.equal(refused.status,500,'this synthetic env configures no sync token'); + assert.deepEqual(await refused.json(),{ok:false,error:'account_facts_sync_token_not_configured'}); + assert.deepEqual(rejections.at(-1), + {endpoint,http_status:500,error_category:'account_facts_sync_token_not_configured'}, + `${path} is attributed to its own sync endpoint, not to a read`); +} assert.ok(!JSON.stringify(rejections).includes('98765'),'no payload value reaches the log stream'); console.warn=realWarn; console.log('account facts history Worker wiring: synthetic KV/DO failures and empty states passed'); diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 006a3c4c..ff885750 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -11834,7 +11834,8 @@ async function syncAccountFactsResponse(request, env, options) { runtimeInstanceCommand, readBoundedJson, requireDedicatedAccountFactsSyncToken, - accountFactsErrorResponse: (error) => accountFactsErrorResponse(error, "history_sync"), + accountFactsErrorResponse: (error) => accountFactsErrorResponse( + error, options?.historical ? "history_sync" : "current_sync"), }, options); }