Skip to content

Commit b3d4ebf

Browse files
Pigbibicodex
andauthored
feat: add opt-in reconciliation recovery state ledger (#428)
Co-authored-by: Codex <noreply@openai.com>
1 parent f06a470 commit b3d4ebf

7 files changed

Lines changed: 389 additions & 19 deletions

‎.github/workflows/sync-cloud-run-env.yml‎

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ jobs:
167167
STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS }}
168168
# Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs.
169169
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
170+
# Empty by default. When explicitly set, the workflow downloads one
171+
# immutable, private recovery ledger before deriving its sync plan.
172+
IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI: ${{ vars.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI }}
170173
GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }}
171174
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
172175
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
@@ -294,6 +297,39 @@ jobs:
294297
with:
295298
python-version: "3.12"
296299

300+
- name: Authenticate to Google Cloud
301+
id: auth
302+
if: steps.config.outputs.enabled == 'true'
303+
uses: google-github-actions/auth@v3
304+
with:
305+
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
306+
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
307+
308+
- name: Set up gcloud
309+
if: steps.config.outputs.enabled == 'true'
310+
uses: google-github-actions/setup-gcloud@v3
311+
with:
312+
project_id: ${{ env.GCP_PROJECT_ID }}
313+
version: ">= 416.0.0"
314+
315+
- name: Fetch opt-in immutable recovery state ledger
316+
if: steps.config.outputs.enabled == 'true' && env.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI != ''
317+
env:
318+
RECOVERY_STATE_LEDGER_URI: ${{ env.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI }}
319+
run: |
320+
set -euo pipefail
321+
case "${RECOVERY_STATE_LEDGER_URI}" in
322+
gs://qsl-reconciliation-recovery-private-ibkr/reconciliation-recovery/ibkr/state/*.json) ;;
323+
*)
324+
echo "Recovery state ledger URI must use the dedicated private immutable prefix." >&2
325+
exit 1
326+
;;
327+
esac
328+
recovery_state_ledger_path="${RUNNER_TEMP}/ibkr-reconciliation-recovery-state-ledger.json"
329+
gcloud storage cp --quiet "${RECOVERY_STATE_LEDGER_URI}" "${recovery_state_ledger_path}" >/dev/null
330+
chmod 600 "${recovery_state_ledger_path}"
331+
echo "IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_PATH=${recovery_state_ledger_path}" >> "$GITHUB_ENV"
332+
297333
- name: Install strategy status dependencies
298334
if: steps.config.outputs.enabled == 'true'
299335
run: |
@@ -406,21 +442,6 @@ jobs:
406442
exit 1
407443
fi
408444
409-
- name: Authenticate to Google Cloud
410-
id: auth
411-
if: steps.config.outputs.enabled == 'true'
412-
uses: google-github-actions/auth@v3
413-
with:
414-
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
415-
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
416-
417-
- name: Set up gcloud
418-
if: steps.config.outputs.enabled == 'true'
419-
uses: google-github-actions/setup-gcloud@v3
420-
with:
421-
project_id: ${{ env.GCP_PROJECT_ID }}
422-
version: ">= 416.0.0"
423-
424445
- name: Require configured scheduler migration before HK verify
425446
if: steps.config.outputs.env_sync_enabled == 'true' && env.WORKFLOW_TARGET == 'hk-verify'
426447
run: |

‎docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,28 @@ AIAuditBridge 的完整 `reconciliation_baseline` 输出。它会同时核验:
5151
券商、执行标记或订单写入代码。下一层单独的最小权限控制器才可消费该计划,并且仍要
5252
在同一目标上比较五项摘要后执行一次精确 CAS。
5353

54+
## 只读状态账本适配层(默认关闭)
55+
56+
为避免把一段旧的 `RUNTIME_TARGET_JSON` 直接写回 GitHub 变量,部署链路可选择读取一份
57+
私有、不可覆盖的状态账本。账本的结构固定为
58+
`ibkr_reconciliation_recovery_state_ledger.v1`,且只含四项:`recovery_id`、
59+
`service_name` 和上述完整 QPK `transition_plan`(另加版本)。它**不**携带下一个运行
60+
目标、账户、策略、仓位、订单或执行权限。
61+
62+
消费时系统从当前完整运行目标推导结果,并逐项验证:服务必须唯一匹配、平台必须为
63+
IBKR、当前状态必须仍是 `RECONCILE_ONLY`、基线 ID 与目标指纹必须等于计划的冻结值,且
64+
QPK 计划中的五项摘要、`no_order=true`、`execution_authority_granted=false` 和 CAS 标志
65+
必须完整存在。验证成功后,唯一允许的差异是
66+
`live_continuity.state: RECONCILE_ONLY -> ACTIVE_LKG`;五项摘要以
67+
`IBKR_RECONCILIATION_EXPECTED_DIGESTS_JSON` 注入运行环境。任何字段缺失、账本重放、目标
68+
漂移、服务不匹配或多服务误匹配都会失败关闭。
69+
70+
工作流只有在仓库变量 `IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI` 显式非空时才会读取
71+
账本;URI 还必须落在专用私有桶的
72+
`reconciliation-recovery/ibkr/state/*.json` 前缀。未设置时不下载账本、不改变同步计划,
73+
既有实盘目标也不受影响。本阶段不写入该变量,也不创建账本对象;因此它只是经过测试的
74+
兼容入口,而不是一次自动或隐式的实盘恢复。
75+
5476
## 故障注入回归
5577

5678
恢复链路的回归测试会主动注入:控制台把不可执行策略篡改为可执行、五项摘要之一

‎scripts/build_cloud_run_env_sync_plan.py‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ def _should_add_local_src(candidate: Path) -> bool:
5757
DEFAULT_MARKET_TIMEZONE,
5858
resolve_market,
5959
)
60+
from scripts.reconciliation_recovery_state_ledger import ( # noqa: E402
61+
apply_recovery_state_ledger_from_env,
62+
)
6063

6164

6265
TARGETS_JSON_ENV = "CLOUD_RUN_SERVICE_TARGETS_JSON"
@@ -103,6 +106,7 @@ def _should_add_local_src(candidate: Path) -> bool:
103106
"IBKR_STRATEGY_CONFIG_PATH",
104107
"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON",
105108
"IBKR_RECONCILIATION_OUTPUT_PATH",
109+
"IBKR_RECONCILIATION_EXPECTED_DIGESTS_JSON",
106110
"IBKR_DRY_RUN_ONLY",
107111
"IBKR_EXECUTION_DEDUP_ENABLED",
108112
"IBKR_PAPER_LIQUIDATE_ONLY",
@@ -329,17 +333,22 @@ def build_sync_plan(env: Mapping[str, str] = os.environ) -> dict[str, object]:
329333
}
330334
for row in get_platform_profile_status_matrix()
331335
}
332-
planned_targets = [
333-
_build_target_plan(
336+
planned_targets = []
337+
recovery_ledger_applied_count = 0
338+
for target in target_entries:
339+
planned_target = _build_target_plan(
334340
target=target,
335341
defaults=defaults,
336342
env=env,
337343
status_rows=status_rows,
338344
per_service_mode=per_service_mode,
339345
platform_config=platform_config,
340346
)
341-
for target in target_entries
342-
]
347+
if planned_target.pop("_recovery_state_ledger_applied", False):
348+
recovery_ledger_applied_count += 1
349+
planned_targets.append(planned_target)
350+
if str(env.get("IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_PATH") or "").strip() and recovery_ledger_applied_count != 1:
351+
raise ValueError("reconciliation recovery state ledger must match exactly one Cloud Run target")
343352
if not planned_targets:
344353
raise ValueError(
345354
f"{TARGETS_JSON_ENV}, CLOUD_RUN_SERVICES, or CLOUD_RUN_SERVICE is required"
@@ -394,6 +403,10 @@ def _build_target_plan(
394403
_target_field(target, defaults, "cloud_run_service"),
395404
)
396405
runtime_target = _resolve_runtime_target(target, defaults, env, per_service_mode)
406+
runtime_target, recovery_expected_digests = apply_recovery_state_ledger_from_env(
407+
runtime_target=runtime_target,
408+
env=env,
409+
)
397410
if not service_name:
398411
service_name = str(runtime_target.get("service_name") or "").strip()
399412
if not service_name:
@@ -516,6 +529,18 @@ def _build_target_plan(
516529
else:
517530
env_values[name] = value
518531

532+
if recovery_expected_digests is not None:
533+
# An immutable ledger is the only source permitted to activate the
534+
# frozen baseline, so it deliberately overrides every normal config
535+
# layer for the accompanying five reconciliation digests.
536+
env_values["IBKR_RECONCILIATION_EXPECTED_DIGESTS_JSON"] = json.dumps(
537+
recovery_expected_digests,
538+
separators=(",", ":"),
539+
sort_keys=True,
540+
)
541+
if "IBKR_RECONCILIATION_EXPECTED_DIGESTS_JSON" in remove_env_vars:
542+
remove_env_vars.remove("IBKR_RECONCILIATION_EXPECTED_DIGESTS_JSON")
543+
519544
if _runtime_target_enabled(env_values):
520545
_validate_profile_inputs(
521546
service_name=service_name,
@@ -553,6 +578,7 @@ def _build_target_plan(
553578
"env": env_values,
554579
"scheduler": scheduler,
555580
"remove_env_vars": sorted(set(remove_env_vars) - set(env_values)),
581+
"_recovery_state_ledger_applied": recovery_expected_digests is not None,
556582
}
557583

558584

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Fail-closed, local consumption of an immutable recovery state ledger.
2+
3+
This module deliberately has no Google Cloud, GitHub, Cloud Run, broker, or
4+
order client. The deployment workflow may opt in by supplying a locally
5+
downloaded ledger file. Without that explicit path, callers retain their
6+
legacy runtime-target behaviour exactly.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import copy
12+
import json
13+
from collections.abc import Mapping
14+
from pathlib import Path
15+
16+
from quant_platform_kit.common.live_continuity import build_live_continuity
17+
from quant_platform_kit.common.reconciliation_recovery import ReconciliationRecoveryTransitionPlan
18+
19+
20+
RECOVERY_STATE_LEDGER_SCHEMA_VERSION = "ibkr_reconciliation_recovery_state_ledger.v1"
21+
RECOVERY_STATE_LEDGER_PATH_ENV = "IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_PATH"
22+
23+
24+
def _ledger_service_name(ledger: Mapping[str, object]) -> str:
25+
service_name = ledger.get("service_name")
26+
if not isinstance(service_name, str) or service_name != service_name.strip() or not 3 <= len(service_name) <= 127:
27+
raise ValueError("reconciliation recovery state ledger service_name is invalid")
28+
return service_name
29+
30+
31+
def apply_recovery_state_ledger(
32+
*,
33+
runtime_target: Mapping[str, object],
34+
ledger: Mapping[str, object],
35+
) -> tuple[dict[str, object], dict[str, str]]:
36+
"""Return the one allowed target transition and its five fixed digests.
37+
38+
A ledger contains only a QPK transition plan. The desired target is never
39+
supplied by the ledger: it is derived from the current target by changing
40+
exactly ``live_continuity.state`` after every frozen-baseline precondition
41+
has been revalidated.
42+
"""
43+
44+
required = {"schema_version", "recovery_id", "service_name", "transition_plan"}
45+
if not isinstance(ledger, Mapping) or set(ledger) != required:
46+
raise ValueError("reconciliation recovery state ledger has invalid fields")
47+
if ledger.get("schema_version") != RECOVERY_STATE_LEDGER_SCHEMA_VERSION:
48+
raise ValueError("unsupported reconciliation recovery state ledger schema")
49+
ledger_service_name = _ledger_service_name(ledger)
50+
51+
raw_plan = ledger.get("transition_plan")
52+
if not isinstance(raw_plan, Mapping):
53+
raise ValueError("reconciliation recovery state ledger is missing transition_plan")
54+
plan = ReconciliationRecoveryTransitionPlan.from_dict(raw_plan)
55+
if str(ledger.get("recovery_id") or "").strip() != plan.recovery_id:
56+
raise ValueError("reconciliation recovery state ledger recovery_id mismatch")
57+
58+
target = copy.deepcopy(dict(runtime_target))
59+
if str(target.get("platform_id") or "").strip().lower() != "ibkr":
60+
raise ValueError("reconciliation recovery state ledger only supports ibkr targets")
61+
if str(target.get("service_name") or "").strip() != ledger_service_name:
62+
raise ValueError("reconciliation recovery state ledger service_name mismatch")
63+
continuity_payload = target.get("live_continuity")
64+
continuity = build_live_continuity(continuity_payload)
65+
continuity.assert_matches_target(target)
66+
if continuity.state != plan.expected_live_continuity_state:
67+
raise ValueError("reconciliation recovery state ledger current continuity state mismatch")
68+
if continuity.baseline_id != plan.baseline_id:
69+
raise ValueError("reconciliation recovery state ledger baseline_id mismatch")
70+
if continuity.baseline_target_sha256 != plan.baseline_target_sha256:
71+
raise ValueError("reconciliation recovery state ledger baseline digest mismatch")
72+
73+
next_continuity = continuity.to_dict()
74+
next_continuity["state"] = plan.next_live_continuity_state
75+
target["live_continuity"] = next_continuity
76+
return target, dict(plan.expected_digests)
77+
78+
79+
def apply_recovery_state_ledger_from_env(
80+
*,
81+
runtime_target: Mapping[str, object],
82+
env: Mapping[str, str],
83+
) -> tuple[dict[str, object], dict[str, str] | None]:
84+
"""Apply an explicitly supplied local ledger, or retain the original target."""
85+
86+
raw_path = str(env.get(RECOVERY_STATE_LEDGER_PATH_ENV) or "").strip()
87+
if not raw_path:
88+
return dict(runtime_target), None
89+
try:
90+
value = json.loads(Path(raw_path).read_text(encoding="utf-8"))
91+
except OSError as exc:
92+
raise ValueError("reconciliation recovery state ledger cannot be read") from exc
93+
except json.JSONDecodeError as exc:
94+
raise ValueError("reconciliation recovery state ledger is not valid JSON") from exc
95+
if not isinstance(value, Mapping):
96+
raise ValueError("reconciliation recovery state ledger must be a JSON object")
97+
ledger_service_name = _ledger_service_name(value)
98+
target_service_name = str(runtime_target.get("service_name") or "").strip()
99+
if target_service_name != ledger_service_name:
100+
return dict(runtime_target), None
101+
target, expected_digests = apply_recovery_state_ledger(
102+
runtime_target=runtime_target,
103+
ledger=value,
104+
)
105+
return target, expected_digests
106+
107+
108+
__all__ = [
109+
"RECOVERY_STATE_LEDGER_PATH_ENV",
110+
"RECOVERY_STATE_LEDGER_SCHEMA_VERSION",
111+
"apply_recovery_state_ledger",
112+
"apply_recovery_state_ledger_from_env",
113+
]

0 commit comments

Comments
 (0)