Skip to content

Commit e865328

Browse files
Pigbibicodex
andauthored
feat(console): add bounded legacy recovery confirmation (#334)
Co-authored-by: Codex <noreply@openai.com>
1 parent fb790de commit e865328

11 files changed

Lines changed: 1054 additions & 3 deletions

File tree

.github/workflows/deploy-strategy-switch-console.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ jobs:
4242
CONTROL_PLANE_SYNC_TOKEN: ${{ secrets.CONTROL_PLANE_SYNC_TOKEN }}
4343
RESEARCH_TASK_SYNC_TOKEN: ${{ secrets.RESEARCH_TASK_SYNC_TOKEN }}
4444
EXECUTION_EVIDENCE_SYNC_TOKEN: ${{ secrets.EXECUTION_EVIDENCE_SYNC_TOKEN }}
45+
RECONCILIATION_RECOVERY_SYNC_TOKEN: ${{ secrets.RECONCILIATION_RECOVERY_SYNC_TOKEN }}
4546
steps:
4647
- name: Checkout
4748
uses: actions/checkout@v6
@@ -165,6 +166,13 @@ jobs:
165166
set -euo pipefail
166167
printf '%s' "$EXECUTION_EVIDENCE_SYNC_TOKEN" | npx wrangler@4.106.0 secret put EXECUTION_EVIDENCE_SYNC_TOKEN --config wrangler.toml
167168
169+
- name: Sync optional reconciliation-recovery ingress token
170+
if: env.RECONCILIATION_RECOVERY_SYNC_TOKEN != ''
171+
working-directory: web/strategy-switch-console
172+
run: |
173+
set -euo pipefail
174+
printf '%s' "$RECONCILIATION_RECOVERY_SYNC_TOKEN" | npx wrangler@4.106.0 secret put RECONCILIATION_RECOVERY_SYNC_TOKEN --config wrangler.toml
175+
168176
- name: Sync M0 research-ledger ingress token
169177
env:
170178
M0_RESEARCH_SYNC_TOKEN: ${{ secrets.M0_RESEARCH_SYNC_TOKEN }}

.github/workflows/manual-strategy-switch.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ jobs:
211211
exit 2
212212
fi
213213
214+
if [ "${LIVE_CONTINUITY_STATE}" != "NONE" ]; then
215+
echo "Legacy continuity recovery is intentionally unavailable from this workflow. Use the private reconciliation recovery controller after fresh read-only evidence, bound dual review, and an owner confirmation." >&2
216+
exit 2
217+
fi
218+
214219
settings_activation="$(
215220
python3 python/scripts/runtime_settings.py settings-activation "${PLATFORM}"
216221
)"

tests/strategy_switch_worker_validation.mjs

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ assert.ok(indexHtml.includes('button.disabled = !supportedModes.includes(button.
8080
assert.ok(indexHtml.includes('function renderPlanReadiness()'));
8181
assert.ok(indexHtml.includes('id="execution-evidence-list"'));
8282
assert.ok(indexHtml.includes('id="execution-evidence-notice"'));
83+
assert.ok(indexHtml.includes('id="reconciliation-recovery-list"'));
84+
assert.ok(indexHtml.includes('id="reconciliation-recovery-notice"'));
85+
assert.ok(indexHtml.includes('requestJson("/api/reconciliation-recovery")'));
86+
assert.ok(indexHtml.includes('data-reconciliation-recovery-confirm'));
8387
assert.ok(indexHtml.includes('P0_CONTROL_PLANE_NOT_RUNTIME_WIRED'));
8488
assert.ok(indexHtml.includes('window.__QSL_RUNTIME_AUTHORITY_STATUS__'));
8589
assert.ok(indexHtml.includes('execution_metadata_is_runtime_authority'));
@@ -827,6 +831,7 @@ const workflowYaml = readFileSync(resolve(root, ".github/workflows/manual-strate
827831
assert.ok(workflowYaml.includes('"live_continuity_state": continuity.get("state", "NONE")'));
828832
assert.ok(workflowYaml.includes('payload["live_continuity_baseline_id"]'));
829833
assert.ok(workflowYaml.includes('"cash_only_execution_mode": "current"'));
834+
assert.ok(workflowYaml.includes('Legacy continuity recovery is intentionally unavailable from this workflow.'));
830835
const workflowInputs = [...workflowYaml.matchAll(/^ ([A-Za-z0-9_]+):\n description:/gm)].map((match) => match[1]);
831836
const dispatchInputs = __test.normalizeSwitchInputs({
832837
platform: "ibkr",
@@ -841,6 +846,40 @@ const dispatchInputs = __test.normalizeSwitchInputs({
841846
for (const key of Object.keys(dispatchInputs)) {
842847
assert.ok(workflowInputs.includes(key), `workflow input missing for dispatch field: ${key}`);
843848
}
849+
const directLegacyRecoveryEnv = {
850+
SESSION_SECRET: "direct-legacy-recovery-session",
851+
ALLOWED_GITHUB_LOGINS: "recovery-admin",
852+
RUNTIME_SETTINGS_DISPATCH_TOKEN: "dispatch-token",
853+
};
854+
const directLegacyRecoveryCookie = await __test.makeSession("recovery-admin", [], directLegacyRecoveryEnv);
855+
const directLegacyRecoveryAttempt = await worker.fetch(
856+
new Request("https://switch.example/api/switch", {
857+
method: "POST",
858+
headers: {
859+
Cookie: `qsl_switch_session=${directLegacyRecoveryCookie}`,
860+
Origin: "https://switch.example",
861+
"Content-Type": "application/json",
862+
},
863+
body: JSON.stringify({
864+
platform: "ibkr",
865+
target_name: "legacy_ibkr_route",
866+
strategy_profile: "soxl_soxx_trend_income",
867+
execution_mode: "live",
868+
live_continuity_state: "RECONCILE_ONLY",
869+
live_continuity_baseline_id: "legacy-ibkr-lkg-20260830",
870+
live_continuity_captured_at: "2026-08-30",
871+
variable_scope: "default",
872+
plugin_mode: "current",
873+
option_overlay_mode: "current",
874+
cash_only_execution_mode: "current",
875+
apply: true,
876+
trigger_platform_sync: true,
877+
confirm_apply: "APPLY_AND_SYNC",
878+
}),
879+
}),
880+
directLegacyRecoveryEnv,
881+
);
882+
assert.equal(directLegacyRecoveryAttempt.status, 409);
844883
const normalizedPluginInputs = __test.normalizeSwitchInputs({
845884
platform: "ibkr",
846885
target_name: "ibkr-primary",
@@ -2476,6 +2515,198 @@ const recordedOwnerDecisionQueue = await worker.fetch(
24762515
const recordedOwnerDecisionPayload = await recordedOwnerDecisionQueue.json();
24772516
assert.equal(recordedOwnerDecisionPayload.candidates[0].intent.decision, "keep_parked");
24782517

2518+
const recoveryStore = new Map();
2519+
const recoveryKv = {
2520+
async get(key) { return recoveryStore.get(key) || null; },
2521+
async put(key, value) { recoveryStore.set(key, value); },
2522+
async list({ prefix = "", limit = 1000 } = {}) {
2523+
return {
2524+
keys: [...recoveryStore.keys()]
2525+
.filter((key) => key.startsWith(prefix))
2526+
.slice(0, limit)
2527+
.map((name) => ({ name })),
2528+
};
2529+
},
2530+
};
2531+
const recoverySyncValue = ["reconciliation", "recovery", "sync"].join("-");
2532+
const recoveryEnv = {
2533+
SESSION_SECRET: "recovery-session-value",
2534+
ALLOWED_GITHUB_LOGINS: "recovery-admin,recovery-reader",
2535+
STRATEGY_SWITCH_ADMIN_LOGINS: "recovery-admin",
2536+
RECONCILIATION_RECOVERY_SYNC_TOKEN: recoverySyncValue,
2537+
STRATEGY_SWITCH_CONFIG: recoveryKv,
2538+
};
2539+
const recoveryAdminCookie = await __test.makeSession("recovery-admin", [], recoveryEnv);
2540+
const recoveryReaderCookie = await __test.makeSession("recovery-reader", [], recoveryEnv);
2541+
const recoveryAdminHeaders = { Cookie: `qsl_switch_session=${recoveryAdminCookie}` };
2542+
const recoveryReaderHeaders = { Cookie: `qsl_switch_session=${recoveryReaderCookie}` };
2543+
const recoveryNow = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
2544+
const recoveryFirstObservation = new Date(Date.parse(recoveryNow) - 60 * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
2545+
const recoveryCandidateSha256 = "a".repeat(64);
2546+
const recoverySourcePayload = {
2547+
schema_version: "qsl_reconciliation_recovery_source_snapshot.v1",
2548+
source_id: "ibkr.legacy_recovery",
2549+
generated_at: recoveryNow,
2550+
computed_at: recoveryNow,
2551+
data_status: "ready",
2552+
recoveries: [{
2553+
recovery_id: "ibkr_legacy_soxl_live",
2554+
platform: "ibkr",
2555+
strategy_profile: "soxl_soxx_trend_income",
2556+
environment: "live",
2557+
reconciliation_state: "RECONCILE_ONLY",
2558+
readiness: "awaiting_human_confirmation",
2559+
candidate_sha256: recoveryCandidateSha256,
2560+
evidence_sample_count: 2,
2561+
first_observed_at: recoveryFirstObservation,
2562+
last_observed_at: recoveryNow,
2563+
dual_review: {
2564+
outcome: "approved",
2565+
reviewer_count: 2,
2566+
evidence_binding_sha256: recoveryCandidateSha256,
2567+
},
2568+
blocker_codes: [],
2569+
}],
2570+
errors: [],
2571+
};
2572+
assert.throws(
2573+
() => __test.normalizeReconciliationRecoverySourceSnapshot({
2574+
...recoverySourcePayload,
2575+
recoveries: [{ ...recoverySourcePayload.recoveries[0], first_observed_at: recoveryNow }],
2576+
}),
2577+
/1-15 minute two-sample window/,
2578+
);
2579+
const unauthorizedRecoveryRead = await worker.fetch(
2580+
new Request("https://switch.example/api/reconciliation-recovery"),
2581+
recoveryEnv,
2582+
);
2583+
assert.equal(unauthorizedRecoveryRead.status, 401);
2584+
const wrongRecoverySync = await worker.fetch(
2585+
new Request("https://switch.example/api/internal/sync-reconciliation-recovery-source", {
2586+
method: "POST",
2587+
headers: { Authorization: "Bearer wrong-token", "Content-Type": "application/json" },
2588+
body: JSON.stringify(recoverySourcePayload),
2589+
}),
2590+
recoveryEnv,
2591+
);
2592+
assert.equal(wrongRecoverySync.status, 401);
2593+
const invalidRecoverySync = await worker.fetch(
2594+
new Request("https://switch.example/api/internal/sync-reconciliation-recovery-source", {
2595+
method: "POST",
2596+
headers: { Authorization: `Bearer ${recoverySyncValue}`, "Content-Type": "application/json" },
2597+
body: JSON.stringify({
2598+
...recoverySourcePayload,
2599+
recoveries: [{
2600+
...recoverySourcePayload.recoveries[0],
2601+
dual_review: { ...recoverySourcePayload.recoveries[0].dual_review, evidence_binding_sha256: "b".repeat(64) },
2602+
}],
2603+
}),
2604+
}),
2605+
recoveryEnv,
2606+
);
2607+
assert.equal(invalidRecoverySync.status, 400);
2608+
const recoverySync = await worker.fetch(
2609+
new Request("https://switch.example/api/internal/sync-reconciliation-recovery-source", {
2610+
method: "POST",
2611+
headers: { Authorization: `Bearer ${recoverySyncValue}`, "Content-Type": "application/json" },
2612+
body: JSON.stringify(recoverySourcePayload),
2613+
}),
2614+
recoveryEnv,
2615+
);
2616+
assert.equal(recoverySync.status, 200);
2617+
const recoveryRead = await worker.fetch(
2618+
new Request("https://switch.example/api/reconciliation-recovery", { headers: recoveryAdminHeaders }),
2619+
recoveryEnv,
2620+
);
2621+
assert.equal(recoveryRead.status, 200);
2622+
const recoveryDashboard = await recoveryRead.json();
2623+
assert.equal(recoveryDashboard.data_status, "ready");
2624+
assert.deepEqual(recoveryDashboard.summary, {
2625+
recovery_count: 1, awaiting_human_confirmation: 1, blocked: 0, confirmed: 0,
2626+
});
2627+
assert.equal(recoveryDashboard.policy.no_order, true);
2628+
assert.equal(recoveryDashboard.policy.execution_authority_granted, false);
2629+
const recoveryConfirmationRequest = {
2630+
recovery_id: "ibkr_legacy_soxl_live",
2631+
candidate_sha256: recoveryCandidateSha256,
2632+
dual_review_binding_sha256: recoveryCandidateSha256,
2633+
};
2634+
const staleRecoveryCandidateSha256 = "c".repeat(64);
2635+
const staleRecoverySourcePayload = {
2636+
...recoverySourcePayload,
2637+
source_id: "ibkr.stale_recovery",
2638+
recoveries: [{
2639+
...recoverySourcePayload.recoveries[0],
2640+
recovery_id: "ibkr_legacy_stale",
2641+
candidate_sha256: staleRecoveryCandidateSha256,
2642+
first_observed_at: new Date(Date.parse(recoveryNow) - 41 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
2643+
last_observed_at: new Date(Date.parse(recoveryNow) - 40 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
2644+
dual_review: {
2645+
outcome: "approved",
2646+
reviewer_count: 2,
2647+
evidence_binding_sha256: staleRecoveryCandidateSha256,
2648+
},
2649+
}],
2650+
};
2651+
const staleRecoverySync = await worker.fetch(
2652+
new Request("https://switch.example/api/internal/sync-reconciliation-recovery-source", {
2653+
method: "POST",
2654+
headers: { Authorization: `Bearer ${recoverySyncValue}`, "Content-Type": "application/json" },
2655+
body: JSON.stringify(staleRecoverySourcePayload),
2656+
}),
2657+
recoveryEnv,
2658+
);
2659+
assert.equal(staleRecoverySync.status, 200);
2660+
const staleRecoveryRead = await worker.fetch(
2661+
new Request("https://switch.example/api/reconciliation-recovery", { headers: recoveryAdminHeaders }),
2662+
recoveryEnv,
2663+
);
2664+
const staleRecoveryDashboard = await staleRecoveryRead.json();
2665+
assert.equal(staleRecoveryDashboard.data_status, "stale");
2666+
const staleDashboardConfirmation = await worker.fetch(
2667+
new Request("https://switch.example/api/reconciliation-recovery-confirmations", {
2668+
method: "POST",
2669+
headers: { ...recoveryAdminHeaders, Origin: "https://switch.example", "Content-Type": "application/json" },
2670+
body: JSON.stringify(recoveryConfirmationRequest),
2671+
}),
2672+
recoveryEnv,
2673+
);
2674+
assert.equal(staleDashboardConfirmation.status, 409);
2675+
recoveryStore.delete("reconciliation_recovery_source:ibkr.stale_recovery");
2676+
const readerRecoveryConfirmation = await worker.fetch(
2677+
new Request("https://switch.example/api/reconciliation-recovery-confirmations", {
2678+
method: "POST",
2679+
headers: { ...recoveryReaderHeaders, Origin: "https://switch.example", "Content-Type": "application/json" },
2680+
body: JSON.stringify(recoveryConfirmationRequest),
2681+
}),
2682+
recoveryEnv,
2683+
);
2684+
assert.equal(readerRecoveryConfirmation.status, 403);
2685+
const recoveryConfirmation = await worker.fetch(
2686+
new Request("https://switch.example/api/reconciliation-recovery-confirmations", {
2687+
method: "POST",
2688+
headers: { ...recoveryAdminHeaders, Origin: "https://switch.example", "Content-Type": "application/json" },
2689+
body: JSON.stringify(recoveryConfirmationRequest),
2690+
}),
2691+
recoveryEnv,
2692+
);
2693+
assert.equal(recoveryConfirmation.status, 200);
2694+
const recoveryConfirmationPayload = await recoveryConfirmation.json();
2695+
assert.equal(recoveryConfirmationPayload.confirmation.no_order, true);
2696+
assert.equal(recoveryConfirmationPayload.confirmation.execution_authority_granted, false);
2697+
assert.ok(recoveryStore.has("reconciliation_recovery_current:ibkr_legacy_soxl_live"));
2698+
assert.ok(recoveryStore.has(
2699+
`reconciliation_recovery_confirmation:ibkr_legacy_soxl_live:${recoveryConfirmationPayload.confirmation.confirmation_sha256}`,
2700+
));
2701+
const recordedRecoveryRead = await worker.fetch(
2702+
new Request("https://switch.example/api/reconciliation-recovery", { headers: recoveryAdminHeaders }),
2703+
recoveryEnv,
2704+
);
2705+
const recordedRecoveryPayload = await recordedRecoveryRead.json();
2706+
assert.equal(recordedRecoveryPayload.summary.confirmed, 1);
2707+
assert.equal(recordedRecoveryPayload.summary.awaiting_human_confirmation, 0);
2708+
assert.equal(recordedRecoveryPayload.recoveries[0].confirmation.confirmed_by, "recovery-admin");
2709+
24792710
const riskProfileStore = new Map();
24802711
const riskProfileKv = {
24812712
async get(key) { return riskProfileStore.get(key) || null; },

web/strategy-switch-console/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ When adding or renaming a strategy profile:
169169

170170
The console only allows live-enabled profiles whose `domain` is included in the selected account's `supported_domains`. If a profile is dynamically read from GitHub Variables but is missing from the catalog, add it to the catalog before switching to it.
171171

172+
## Existing Live Recovery Verification
173+
174+
An already authorised target that entered `RECONCILE_ONLY` uses a separate, non-executable recovery path rather than the P6 new-strategy queue. A private runtime publishes a redacted `qsl_reconciliation_recovery_source_snapshot.v1` to `POST /api/internal/sync-reconciliation-recovery-source` with a dedicated `RECONCILIATION_RECOVERY_SYNC_TOKEN`; allowlisted users read `GET /api/reconciliation-recovery`, and an administrator may record `POST /api/reconciliation-recovery-confirmations`.
175+
176+
An item can await confirmation only when it remains `RECONCILE_ONLY`, has two or more read-only samples separated by 1–15 minutes, has a current candidate digest, two approved reviews bound to that same digest, and no blocker. Source and candidate evidence expire after 30 minutes by default. The confirmation is an immutable `no_order=true`, `execution_authority_granted=false` intent only: it cannot dispatch a workflow, read broker credentials, change an account, restore runtime, or place an order. A private platform controller must re-verify raw receipts and the dual-review binding before it may restore the pre-existing runtime; `manual-strategy-switch.yml` rejects all legacy continuity states so it cannot bypass this boundary.
177+
172178
## GitHub OAuth App
173179

174180
Create a GitHub OAuth App:
@@ -196,6 +202,7 @@ wrangler secret put SESSION_SECRET
196202
wrangler secret put RUNTIME_SETTINGS_DISPATCH_TOKEN
197203
wrangler secret put STRATEGY_SWITCH_SYNC_TOKEN # optional; defaults to RUNTIME_SETTINGS_DISPATCH_TOKEN
198204
wrangler secret put M0_RESEARCH_SYNC_TOKEN
205+
wrangler secret put RECONCILIATION_RECOVERY_SYNC_TOKEN
199206
wrangler secret put ALLOWED_GITHUB_LOGINS
200207
wrangler secret put ALLOWED_GITHUB_ORGS
201208
wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS
@@ -211,7 +218,7 @@ wrangler kv namespace create STRATEGY_SWITCH_CONFIG
211218

212219
Add the returned namespace id to `wrangler.toml`.
213220

214-
For GitHub Actions auto-deploy, configure `STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID`, `STRATEGY_SWITCH_CONSOLE_URL`, `STRATEGY_SWITCH_SYNC_TOKEN`, `M0_RESEARCH_SYNC_TOKEN`, and either `CLOUDFLARE_API_TOKEN` or `CLOUDFLARE_WRANGLER_CONFIG_TOML` in the `runtime-strategy-switch` environment (or reuse `RUNTIME_SETTINGS_GH_TOKEN` only if it matches the Worker sync secret). `CLOUDFLARE_ACCOUNT_ID` is optional when Wrangler can infer it from the token. `M0_RESEARCH_SYNC_TOKEN` must match the separately protected `m0-research-publisher` environment secret; it is only copied to the Worker binding. A missing M0 token fails the deployment before it can retain a stale Worker secret. The workflow deploys the Worker and then syncs the bundled strategy profile catalog into KV so the website is not left with stale profile/plugin metadata.
221+
For GitHub Actions auto-deploy, configure `STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID`, `STRATEGY_SWITCH_CONSOLE_URL`, `STRATEGY_SWITCH_SYNC_TOKEN`, `M0_RESEARCH_SYNC_TOKEN`, and either `CLOUDFLARE_API_TOKEN` or `CLOUDFLARE_WRANGLER_CONFIG_TOML` in the `runtime-strategy-switch` environment (or reuse `RUNTIME_SETTINGS_GH_TOKEN` only if it matches the Worker sync secret). Add a separate `RECONCILIATION_RECOVERY_SYNC_TOKEN` before enabling any legacy-recovery publisher; the deploy synchronizes it only when it is present. `CLOUDFLARE_ACCOUNT_ID` is optional when Wrangler can infer it from the token. `M0_RESEARCH_SYNC_TOKEN` must match the separately protected `m0-research-publisher` environment secret; it is only copied to the Worker binding. A missing M0 token fails the deployment before it can retain a stale Worker secret. The workflow deploys the Worker and then syncs the bundled strategy profile catalog into KV so the website is not left with stale profile/plugin metadata.
215222

216223
An authenticated retry carrying the exact same immutable M0 source-artifact SHA is acknowledged with `200` and `replayed: true`, without another KV write. A different source/run replay or a ledger-time rollback remains rejected with `409`.
217224

0 commit comments

Comments
 (0)