Skip to content

Commit 5ffadbf

Browse files
Pigbibicodex
andauthored
fix: adopt source-bound reconciliation recovery with advisory review (#480)
Co-authored-by: Codex <noreply@openai.com>
1 parent f2155dc commit 5ffadbf

14 files changed

Lines changed: 395 additions & 114 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
- name: Check QPK pin consistency
6060
run: |
6161
set -euo pipefail
62-
QPK_EXPECTED_PIN=7f140f07ac89f0b4b88347a903906825dde11c39 uv run --no-sync python scripts/check_qpk_pin_consistency.py
62+
QPK_EXPECTED_PIN=c4f9b7599041406d4693083bf2eb4e85d6e10059 uv run --no-sync python scripts/check_qpk_pin_consistency.py
6363
6464
- name: Ensure uv.lock matches pyproject.toml
6565
run: uv lock --check

application/broker_reconciliation_candidate.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ def _canonical_workflow_path(value: object) -> str:
109109
def _normalize_expectations(
110110
expectations: Sequence[SourceReceiptExpectation],
111111
) -> dict[str, dict[str, str]]:
112-
if len(expectations) != 2:
113-
raise ValueError("exactly two audit-designated source receipts are required")
112+
if not expectations:
113+
raise ValueError("at least one designated source receipt is required")
114114
normalized: dict[str, dict[str, str]] = {}
115115
for expectation in expectations:
116116
if type(expectation) is not SourceReceiptExpectation:
@@ -242,13 +242,13 @@ def canonical_source_receipt_records_json(
242242
strategy_profile: str,
243243
expectations: Sequence[SourceReceiptExpectation],
244244
) -> str:
245-
"""Validate and canonically serialize exactly two redacted source records."""
245+
"""Validate and canonically serialize designated redacted source records."""
246246

247247
profile = _canonical_text(strategy_profile, field_name="strategy_profile")
248248
expected_by_artifact = _normalize_expectations(expectations)
249249
normalized = tuple(_normalize_source_record(record) for record in records)
250-
if len(normalized) != 2:
251-
raise ValueError("exactly two source receipt records are required")
250+
if not normalized:
251+
raise ValueError("at least one source receipt record is required")
252252
if len({_source_record_key(record) for record in normalized}) != len(normalized):
253253
raise ValueError("source receipt artifact/run pairs must be unique")
254254
if {record["artifact_id"] for record in normalized} != set(expected_by_artifact):
@@ -280,7 +280,7 @@ def calculate_source_receipts_sha256(
280280
strategy_profile: str,
281281
expectations: Sequence[SourceReceiptExpectation],
282282
) -> str:
283-
"""Return the root binding two validated, redacted source records."""
283+
"""Bind validated source content; the caller must independently verify its origin."""
284284

285285
return hashlib.sha256(
286286
canonical_source_receipt_records_json(
@@ -319,6 +319,7 @@ def build_reconciliation_candidate_v2(
319319
strategy_profile=normalized_candidate.strategy_profile,
320320
expectations=expectations,
321321
)
322+
_validate_evidence_members(normalized_candidate, records)
322323
payload: dict[str, Any] = normalized_candidate.to_dict()
323324
payload["schema_version"] = (
324325
BROKER_RECONCILIATION_BASELINE_CANDIDATE_V2_SCHEMA_VERSION
@@ -331,7 +332,41 @@ def build_reconciliation_candidate_v2(
331332
return BrokerReconciliationBaselineCandidate.from_dict(payload)
332333

333334

335+
def _validate_evidence_members(
336+
candidate: BrokerReconciliationBaselineCandidate,
337+
records: Sequence[Mapping[str, object]],
338+
) -> None:
339+
members = [record.get("evidence_sha256") for record in records]
340+
if sorted(members) != sorted(candidate.source_evidence_sha256):
341+
raise ValueError("source records must match candidate evidence members exactly")
342+
343+
344+
def validate_reconciliation_candidate_sources(
345+
candidate: BrokerReconciliationBaselineCandidate,
346+
*,
347+
source_receipt_records: Iterable[Mapping[str, object]],
348+
expectations: Sequence[SourceReceiptExpectation],
349+
) -> BrokerReconciliationBaselineCandidate:
350+
"""Recheck private source content against independently designated expectations.
351+
352+
These inputs bind provenance, not accounting completeness or live authority.
353+
The caller must obtain expectations independently of untrusted candidate data.
354+
"""
355+
candidate = BrokerReconciliationBaselineCandidate.from_dict(candidate.to_dict())
356+
if candidate.schema_version != BROKER_RECONCILIATION_BASELINE_CANDIDATE_V2_SCHEMA_VERSION:
357+
raise ValueError("a source-bound v2 candidate is required")
358+
records = tuple(source_receipt_records)
359+
root = calculate_source_receipts_sha256(
360+
records, strategy_profile=candidate.strategy_profile, expectations=expectations,
361+
)
362+
_validate_evidence_members(candidate, records)
363+
if root != candidate.source_receipts_sha256:
364+
raise ValueError("candidate source receipts binding mismatch")
365+
return candidate
366+
367+
334368
__all__ = [
369+
"validate_reconciliation_candidate_sources",
335370
"SOURCE_RECEIPT_RECORD_SCHEMA_VERSION",
336371
"SourceReceiptExpectation",
337372
"build_reconciliation_candidate_v2",

docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
# IBKR 旧实盘基线:审核材料生成
22

3-
`scripts/build_reconciliation_baseline_candidate.py` 只处理两份或更多私有的、已脱敏
4-
`/reconcile` 回应或运行报告。它调用 QuantPlatformKit 的通用规则,验证这些收据是
5-
新鲜、时间分离、账户身份一致且全部状态摘要相同,然后输出
6-
`broker_reconciliation_baseline_candidate.v1`
3+
`scripts/build_reconciliation_baseline_candidate.py` 处理一份或更多私有、已脱敏的
4+
`/reconcile` 回应。调用方同时显式提供已保存的 source records 和独立核验的
5+
`SourceReceiptExpectation`。现有 record/expectation 字段逐项一致、evidence 成员精确对应
6+
后,才调用 QPK 生成既有 source-bound `broker_reconciliation_baseline_candidate.v2`
7+
收据必须新鲜、账户/runtime 匹配且各账务面已对账;不再要求第二份或固定最小间隔。
78

89
该工具不连接 IB Gateway、不访问原始账户资料、不写 Cloud Run 环境变量、不改
910
`RUNTIME_TARGET_JSON`,更不会下单。它的非零退出码表示候选尚不能进入审计;这不是
1011
故障恢复授权。
1112

12-
私有控制面将候选的 `candidate_sha256` 交给 AIAuditBridge 的
13-
`reconciliation_baseline` 强制双审。双审结果与候选摘要一致后,统一管理站点仍必须
14-
让操作者人工确认“恢复原有实盘基线”。现有自动化权限策略将这类 broker/order
15-
execution 变更视为高风险,禁止自动恢复
13+
模型审查仅为 advisory,省略时真实显示 unavailable/0;已有 rejected/unavailable 不
14+
改写成 approved,不要求多模型票数或伪造 `dual_review_binding_reverified=True`
15+
人工确认、确认后的新观察、五摘要比较和 CAS/no-order 约束继续保留;本补丁不批准
16+
真实账户接管、live、下单或扩大资金
1617

1718
## 发布给统一管理站点的最小来源快照
1819

19-
`scripts/publish_reconciliation_recovery_source.py` 只接受上一步的私有候选和
20-
AIAuditBridge 的完整 `reconciliation_baseline` 输出。它会同时核验:强制多审已
21-
执行、至少一名主审和一名独立复审存在、结论通过、候选 SHA-256 完全绑定,以及
22-
审计结果仍明确要求人工恢复确认和 `escalate` 权限边界。
20+
`scripts/publish_reconciliation_recovery_source.py` 接受候选、独立来源文件及可选完整审查。
21+
它重新核验 record/expectation、来源根及 candidate evidence 成员;已有审查仍须绑定同一
22+
候选,并保持人工确认/escalate 边界。票数和模型结论不决定是否可以进入人工确认。
2323

2424
默认行为只是打印 `qsl_reconciliation_recovery_source_snapshot.v1`,其中只有不透明
2525
恢复 ID、平台/策略、候选摘要、采样时间窗、审计绑定与稳定阻断码;不含账号、仓位、
@@ -29,19 +29,21 @@ AIAuditBridge 的完整 `reconciliation_baseline` 输出。它会同时核验:
2929
`/api/internal/sync-reconciliation-recovery-source` 地址,且环境中存在专用的
3030
`RECONCILIATION_RECOVERY_SYNC_TOKEN` 时,脚本才会把上述最小快照发给统一管理站点。
3131
该写入仅供人工确认队列使用,不能恢复 `ACTIVE_LKG`。后续私有控制器仍须重新读取
32-
券商、复核双审绑定,并以原子比较并设置方式转换状态;任一失败都保持
32+
券商、重验来源及候选绑定,并以原子比较并设置方式转换状态;任一失败都保持
3333
`RECONCILE_ONLY`
3434

3535
来源发布器还可在显式给出
3636
`gs://.../reconciliation-recovery/ibkr/source/...` 时,把完整候选与双审回执写入
37-
私有证据包。该包不发送给 QRS;写入固定使用 GCS `if_generation_match=0`,所以已存在
37+
私有证据包(缺审查保存 null,不制造审核结果)。来源文件由调用方独立保留,不从
38+
待验证包中自造受信 expectations。该包不发送给 QRS;写入固定使用 GCS `if_generation_match=0`,所以已存在
3839
对象会失败而不会被覆盖、读取或删除。验证器只能在专用私有存储中读取它。
3940

4041
## 私有验证器(暂不写状态)
4142

4243
`scripts/verify_reconciliation_recovery.py` 是恢复链路的第二层。它使用一枚不同于
4344
来源发布令牌的 `RECONCILIATION_RECOVERY_CONTROLLER_TOKEN`,从 QRS 只读取得已确认
44-
条目;然后重新解析本地私有候选与完整双审回执,读取一份**确认之后**新生成的
45+
条目;然后重新验证本地私有候选、独立指定的 source records/expectations 与可选审查,
46+
要求控制台样本数等于候选真实成员数,读取一份**确认之后**新生成的
4547
`/reconcile` 回执,并核对已部署 `RUNTIME_TARGET_JSON` 仍为同一
4648
`RECONCILE_ONLY` 基线。
4749

@@ -88,7 +90,7 @@ URI、部署 Cloud Run、连接券商或提交订单。实际启用仍需要单
8890
`transition_plan` 的结果;测试同时断言 `state_write_attempted=false`。这让后续接入
8991
最小权限 CAS 时能持续证明“异常只能保持冻结,不能意外恢复实盘”。
9092

91-
## 收集两份候选收据
93+
## 收集候选收据
9294

9395
`Collect IBKR Reconciliation Evidence` 是显式手动工作流。由于这些 Cloud Run 服务只接受
9496
内部入口,工作流会以部署身份创建一个名称绑定到本次运行、几分钟后只执行一次的 Cloud
@@ -101,8 +103,8 @@ Scheduler 任务,再由既有的最小权限 Scheduler 身份调用冻结服
101103
`broker_reconciliation` 摘要;`build_reconciliation_baseline_candidate.py` 可直接消费它。
102104
这样基线审核不必下载包含其他运行诊断的完整私有报告。
103105

104-
同一目标至少应在相隔一分钟的两次手动运行中得到候选,才能交给
105-
`build_reconciliation_baseline_candidate.py`。工作流的成功只说明读取和收据格式正常;
106+
同一目标的一份完整、可信来源收据即可交给
107+
`build_reconciliation_baseline_candidate.py`,不因凑样本重复触发采集。工作流的成功只说明读取和收据格式正常;
106108
候选仍可能因为未配置预期摘要或账本差异而正确保持阻断。
107109

108110
其中现金摘要只选择结算/账面现金标签(例如 `CashBalance`),不会把随市价变化的
@@ -111,18 +113,27 @@ Scheduler 任务,再由既有的最小权限 Scheduler 身份调用冻结服
111113

112114
## v2 来源根(仅私有候选构造)
113115

114-
`application.broker_reconciliation_candidate` **恰好两份**已保存、已脱敏的 source
115-
record 绑定到既有 v1 候选,显式生成
116+
`application.broker_reconciliation_candidate` 校验**至少一份**已保存、已脱敏的 source
117+
record。新 builder 直接生成
116118
`broker_reconciliation_baseline_candidate.v2`。每条 record 固定且只允许
117119
`schema_version``repository``workflow_path``workflow_run_id`
118120
`workflow_run_attempt``workflow_head_sha``artifact_id``artifact_name`
119121
`artifact_sha256``evidence_sha256``service_name``service_revision`
120-
`service_revision_commit_sha``service_deploy_run_id`构造器要求恰好两条,唯一
122+
`service_revision_commit_sha``service_deploy_run_id`构造器要求来源数量一致、唯一
121123
artifact/run;审计指定的 expectation 还将 candidate profile、`main` 成功 workflow、
122-
artifact 命名,以及相同 repository/workflow/head 与 service/revision/commit 绑定到这两条
124+
artifact 命名,以及相同 repository/workflow/head 与 service/revision/commit 绑定到各条
123125
record。随后将完整 canonical records 的单一根写入 `source_receipts_sha256`;任一缺失、
124126
额外字段或不一致均失败关闭。
125127

126128
该模块是无 I/O 的 private consumer:不查询 GitHub/Cloud Run,不启动 workflow,不接触
127129
broker/account/order,也不读取或写入 expected digest、`ACTIVE_LKG` 或 publisher。在线读取和
128130
持久化来源记录属于后续受控 recorder,不在此范围内。
131+
132+
三个 CLI 均要求 `--source-records <私有JSON列表>`
133+
`--source-expectations <独立核验的私有JSON列表>`;发布器和验证器的 `--dual-review` 可省略。
134+
这些参数不触发来源下载或批准;验证器的既有确认读取、发布器的显式发布/存储副作用不变。
135+
136+
来源根只绑定内容,不证明账户身份、查询完整性、账务解释或权限。expectations 必须由
137+
受信调用方独立核验,不能从待验证 records/candidate 自我生成。合成测试只证明这些
138+
consumer 实际接线,不证明任何真实账户安全。历史无来源 v1 只读保留,publisher/verifier
139+
拒绝新申请;旧显式 v1→v2 helper 仍要求完整独立来源校验及成员关联,不用于新单份路径。

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ dependencies = [
2121
"google-cloud-secret-manager",
2222
"google-cloud-storage",
2323
"yfinance",
24-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@7f140f07ac89f0b4b88347a903906825dde11c39",
24+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c4f9b7599041406d4693083bf2eb4e85d6e10059",
2525
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@59a6e4341bf12cf1e27953b9c2d3705bc0335d96",
2626
"hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@709e5e1cde7841aed538d94eb26b552b46cb7806",
2727
]
@@ -64,5 +64,5 @@ include = [
6464

6565
[tool.uv]
6666
override-dependencies = [
67-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@7f140f07ac89f0b4b88347a903906825dde11c39",
67+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c4f9b7599041406d4693083bf2eb4e85d6e10059",
6868
]

qsl.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ upgrade_ring = "ring_d"
55
allow_legacy = false
66

77
[qsl.requires]
8-
quant_platform_kit = "7f140f07ac89f0b4b88347a903906825dde11c39"
8+
quant_platform_kit = "c4f9b7599041406d4693083bf2eb4e85d6e10059"
99
us_equity_strategies = "59a6e4341bf12cf1e27953b9c2d3705bc0335d96"
1010
hk_equity_strategies = "709e5e1cde7841aed538d94eb26b552b46cb7806"
1111

scripts/build_reconciliation_baseline_candidate.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@
1111

1212
import argparse
1313
import json
14-
from collections.abc import Iterable, Mapping
14+
from collections.abc import Iterable, Mapping, Sequence
1515
from datetime import datetime
1616
from pathlib import Path
1717
from typing import Any
1818

19+
from application.broker_reconciliation_candidate import (
20+
SourceReceiptExpectation, calculate_source_receipts_sha256, validate_reconciliation_candidate_sources,
21+
)
22+
1923
from quant_platform_kit.common.broker_reconciliation import BrokerReconciliationEvidence
2024
from quant_platform_kit.common.broker_reconciliation_enrollment import (
2125
evaluate_broker_reconciliation_baseline_enrollment,
@@ -48,19 +52,30 @@ def extract_reconciliation_evidence(payload: Mapping[str, Any]) -> BrokerReconci
4852
def evaluate_receipts(
4953
payloads: Iterable[Mapping[str, Any]],
5054
*,
55+
source_receipt_records: Sequence[Mapping[str, object]],
56+
expectations: Sequence[SourceReceiptExpectation],
5157
now: datetime | None = None,
5258
) -> dict[str, object]:
5359
"""Return a redacted candidate or stable findings for a private controller."""
5460

5561
evidences = [extract_reconciliation_evidence(payload) for payload in payloads]
56-
evaluation = evaluate_broker_reconciliation_baseline_enrollment(evidences, now=now)
62+
if not evidences:
63+
raise ValueError("at least one reconciliation receipt is required")
64+
root = calculate_source_receipts_sha256(
65+
source_receipt_records, strategy_profile=evidences[0].strategy_profile, expectations=expectations,
66+
)
67+
if sorted(record["evidence_sha256"] for record in source_receipt_records) != sorted(item.evidence_sha256 for item in evidences):
68+
raise ValueError("source records must match receipt evidence members exactly")
69+
evaluation = evaluate_broker_reconciliation_baseline_enrollment(evidences, now=now, source_receipts_sha256=root)
5770
result: dict[str, object] = {
5871
"schema_version": "ibkr_reconciliation_baseline_enrollment.v1",
5972
"ready_for_independent_review": evaluation.ready_for_independent_review,
6073
"findings": [finding.value for finding in evaluation.findings],
6174
}
6275
if evaluation.candidate is not None:
63-
result["candidate"] = evaluation.candidate.to_dict()
76+
result["candidate"] = validate_reconciliation_candidate_sources(
77+
evaluation.candidate, source_receipt_records=source_receipt_records, expectations=expectations,
78+
).to_dict()
6479
return result
6580

6681

@@ -76,6 +91,21 @@ def _load_payload(path: Path) -> dict[str, Any]:
7691
return value
7792

7893

94+
def load_source_inputs(
95+
records_path: Path, expectations_path: Path,
96+
) -> tuple[list[Mapping[str, object]], tuple[SourceReceiptExpectation, ...]]:
97+
"""Load existing private record lists, never infer trusted expectations."""
98+
try:
99+
records = json.loads(records_path.read_text(encoding="utf-8"))
100+
raw_expectations = json.loads(expectations_path.read_text(encoding="utf-8"))
101+
if not isinstance(records, list) or not isinstance(raw_expectations, list):
102+
raise ValueError("source inputs must be lists")
103+
expectations = tuple(SourceReceiptExpectation(**item) for item in raw_expectations)
104+
except (OSError, TypeError, ValueError) as exc:
105+
raise ValueError("private source inputs are invalid or unavailable") from exc
106+
return records, expectations
107+
108+
79109
def main(argv: list[str] | None = None) -> int:
80110
parser = argparse.ArgumentParser(
81111
description="Create a non-authorising legacy IBKR reconciliation baseline candidate."
@@ -85,15 +115,17 @@ def main(argv: list[str] | None = None) -> int:
85115
action="append",
86116
type=Path,
87117
required=True,
88-
help="Private /reconcile response or persisted runtime report; supply at least twice.",
118+
help="Private /reconcile response or persisted runtime report; supply at least once.",
89119
)
120+
parser.add_argument("--source-records", type=Path, required=True, help="Private saved source record list")
121+
parser.add_argument("--source-expectations", type=Path, required=True, help="Independently verified private source expectations")
90122
parser.add_argument("--now", help="Optional ISO-8601 time used for deterministic validation.")
91123
args = parser.parse_args(argv)
92-
if len(args.receipt) < 2:
93-
parser.error("--receipt must be supplied at least twice")
94124
try:
95125
reference_now = datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now else None
96-
result = evaluate_receipts((_load_payload(path) for path in args.receipt), now=reference_now)
126+
records, expectations = load_source_inputs(args.source_records, args.source_expectations)
127+
result = evaluate_receipts((_load_payload(path) for path in args.receipt), now=reference_now,
128+
source_receipt_records=records, expectations=expectations)
97129
except ValueError as exc:
98130
parser.error(str(exc))
99131
print(json.dumps(result, ensure_ascii=False, sort_keys=True))

0 commit comments

Comments
 (0)