Skip to content

Commit 0dfd2bf

Browse files
authored
P0-04/P0-08:workflow allowlist校准 + account-diagnosis 409 reason codes (#587)
* fix(qrs): P0-04 workflow allowlist校准 + P0-08 diagnosis 409 reason codes - 校准 github_workflow_allowlist:保留 LB PAPER once;Schwab 改为 runtime-daily-sync; 移除 IBKR/Binance 404;Firstrade 无 daily projection 不加入;omitted 表显式登记 - 文档明确 workflow success ≠ 成交;PAPER once ≠ live - account-diagnosis 409 拆分为稳定 reason_code(身份冲突/缺来源/不可用等), 不绕过验证;前端展示 reason_code * fix(ci): regenerate catalog projection; keep getJson http_* message - Rebuild runtime-catalog-projection/config.js sha after allowlist edit - Preserve Error.message as http_${status} for overview history callers; diagnosis still reads reason_code/payload (P0-08)
1 parent 45e2ad0 commit 0dfd2bf

15 files changed

Lines changed: 351 additions & 65 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# 日报 GitHub workflow allowlist 校准(P0-04)
2+
3+
审计基准:2026-10-09 公开仓 raw/API 取证。
4+
5+
## 语义边界(必读)
6+
7+
- **workflow success ≠ 成交/订单事实。** allowlist 命中只贡献“当日可能有过一次运行投影/发布”的存在性辅助证据;`fill_count` / `order_count` 仍为 unknown,除非 `DIGEST_CANDIDATES` 提供已知计数。
8+
- **PAPER once ≠ live。** LongBridge `publish-runtime-daily-once.yml` 绑定 `longbridge-paper` Environment,是手动一次性 PAPER 投影,不得解读为 live 运行或正式成交。
9+
- **heartbeat-only 永不进入 allowlist。** `execution-report-heartbeat.yml` / `runtime-heartbeat.yml` 是监控心跳,不是业务日投影入口。
10+
- **业务证据优先候选管道。** 正式权益/持仓/信号/调仓应走 `DIGEST_CANDIDATES_*`;workflow 文件名仅作运维定位。
11+
12+
## 2026-10-09 路径核对
13+
14+
| platform_id | 原 allowlist | 公开仓结果 | 校准后 |
15+
| --- | --- | --- | --- |
16+
| longbridge | `publish-runtime-daily-once.yml` | 存在;name=`Publish PAPER Daily Runtime Once`;`workflow_dispatch`;PAPER | 保留;`evidence_role=paper_projection_once` |
17+
| schwab | `publish-runtime-daily-once.yml` | **404**;正式手动入口为 `runtime-daily-sync.yml`(`Schwab Runtime Daily Manual`) | 改为 `runtime-daily-sync.yml`;`evidence_role=manual_daily_projection` |
18+
| ibkr | `publish-runtime-daily-once.yml` | **404**;无 `runtime-daily-sync.yml` | 移入 omitted(`missing`) |
19+
| binance | `publish-runtime-daily-once.yml` | **404**;另有 facts/heartbeat,非 daily projection | 移入 omitted(`missing`) |
20+
| firstrade | (未列入) | 无 daily projection workflow;仅 heartbeat 等 | omitted(`no_daily_projection_workflow`);不加入 allowlist |
21+
22+
配置真源:`platform-config.json` → `notifications.quant_sentinel.daily_digest.aggregator.github_workflow_allowlist` 与 `github_workflow_allowlist_omitted`。
23+
24+
## 验收
25+
26+
```bash
27+
python3 -m unittest \
28+
python.tests.test_daily_digest_aggregator.DailyDigestAggregatorTests.test_allowlist_loaded_from_platform_config \
29+
python.tests.test_daily_digest_aggregator.DailyDigestAggregatorTests.test_allowlist_calibrated_paths_and_roles \
30+
python.tests.test_runtime_settings.RuntimeSettingsTest.test_notification_route_is_runtime_reference_only -v
31+
```
32+
33+
重新取证时冻结 SHA,不要把当时的 main 自动等同线上生产。

‎platform-config.json‎

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -119,32 +119,56 @@
119119
"platform_id": "longbridge",
120120
"repository": "QuantStrategyLab/LongBridgePlatform",
121121
"workflow": "publish-runtime-daily-once.yml",
122-
"strategy_profile": "russell_top50_leader_rotation"
122+
"strategy_profile": "russell_top50_leader_rotation",
123+
"evidence_role": "paper_projection_once",
124+
"path_status": "present",
125+
"note_zh": "PAPER 环境手动一次性投影;workflow success 仅作存在性证据,不成交事实;≠ live。",
126+
"note_en": "PAPER env manual one-shot projection; workflow success is existence-only, not fill facts; not live."
123127
},
124128
{
125129
"platform_id": "schwab",
126130
"repository": "QuantStrategyLab/CharlesSchwabPlatform",
127-
"workflow": "publish-runtime-daily-once.yml",
128-
"strategy_profile": "soxl_soxx_trend_income"
129-
},
131+
"workflow": "runtime-daily-sync.yml",
132+
"strategy_profile": "soxl_soxx_trend_income",
133+
"evidence_role": "manual_daily_projection",
134+
"path_status": "present",
135+
"note_zh": "正式入口为 runtime-daily-sync.yml(原 publish-runtime-daily-once.yml 404);仍为存在性证据,counts 未知。",
136+
"note_en": "Canonical entry is runtime-daily-sync.yml (publish-runtime-daily-once.yml 404); existence-only, counts unknown."
137+
}
138+
],
139+
"binance_alignment_note_zh": "Binance 历史 GH secret 名 TG_TOKEN 仍独立;对齐步骤:将 TG_TOKEN 换成与 quant-sentinel-telegram-bot-token 同一 bot,或改为 TELEGRAM_TOKEN_SECRET_NAME 模式。公开仓只记名称。",
140+
"binance_alignment_note_en": "Binance still uses legacy GH secret name TG_TOKEN; align by pointing TG_TOKEN at the same bot as quant-sentinel-telegram-bot-token, or migrate to TELEGRAM_TOKEN_SECRET_NAME. Public repo records names only.",
141+
"workflow_install_path": ".github/workflows/daily-digest-notify.yml",
142+
"workflow_install_note_zh": "公开 OAuth 无 workflow scope 时,本仓以 docs/workflows/ 为真源文稿;具备 workflow 权限的维护者拷贝到 .github/workflows/ 后 schedule/dispatch 才会生效。",
143+
"workflow_install_note_en": "When the pushing credential lacks the workflow OAuth scope, docs/workflows/ is the authored source; a maintainer with workflow scope must copy it to .github/workflows/ before schedule/dispatch runs.",
144+
"github_workflow_allowlist_omitted": [
130145
{
131146
"platform_id": "ibkr",
132147
"repository": "QuantStrategyLab/InteractiveBrokersPlatform",
133-
"workflow": "publish-runtime-daily-once.yml",
134-
"strategy_profile": "soxl_soxx_trend_income"
148+
"requested_workflow": "publish-runtime-daily-once.yml",
149+
"path_status": "missing",
150+
"reason_zh": "公开仓无 publish-runtime-daily-once.yml / runtime-daily-sync.yml;heartbeat 故意不进日报 allowlist。",
151+
"reason_en": "No publish-runtime-daily-once.yml or runtime-daily-sync.yml in public tree; heartbeat intentionally omitted from digest allowlist."
135152
},
136153
{
137154
"platform_id": "binance",
138155
"repository": "QuantStrategyLab/BinancePlatform",
139-
"workflow": "publish-runtime-daily-once.yml",
140-
"strategy_profile": "crypto_equity_combo"
156+
"requested_workflow": "publish-runtime-daily-once.yml",
157+
"path_status": "missing",
158+
"reason_zh": "公开仓无该文件;binance-account-facts / runtime-heartbeat 不是 runtime daily projection,不冒充成交证据。",
159+
"reason_en": "File absent; binance-account-facts / runtime-heartbeat are not runtime daily projection and must not impersonate fill evidence."
160+
},
161+
{
162+
"platform_id": "firstrade",
163+
"repository": "QuantStrategyLab/FirstradePlatform",
164+
"requested_workflow": null,
165+
"path_status": "no_daily_projection_workflow",
166+
"reason_zh": "公开仓无 daily projection workflow;仅有 execution-report-heartbeat,不加入 allowlist。",
167+
"reason_en": "No daily projection workflow in public tree; only execution-report-heartbeat, not added to allowlist."
141168
}
142169
],
143-
"binance_alignment_note_zh": "Binance 历史 GH secret 名 TG_TOKEN 仍独立;对齐步骤:将 TG_TOKEN 换成与 quant-sentinel-telegram-bot-token 同一 bot,或改为 TELEGRAM_TOKEN_SECRET_NAME 模式。公开仓只记名称。",
144-
"binance_alignment_note_en": "Binance still uses legacy GH secret name TG_TOKEN; align by pointing TG_TOKEN at the same bot as quant-sentinel-telegram-bot-token, or migrate to TELEGRAM_TOKEN_SECRET_NAME. Public repo records names only.",
145-
"workflow_install_path": ".github/workflows/daily-digest-notify.yml",
146-
"workflow_install_note_zh": "公开 OAuth 无 workflow scope 时,本仓以 docs/workflows/ 为真源文稿;具备 workflow 权限的维护者拷贝到 .github/workflows/ 后 schedule/dispatch 才会生效。",
147-
"workflow_install_note_en": "When the pushing credential lacks the workflow OAuth scope, docs/workflows/ is the authored source; a maintainer with workflow scope must copy it to .github/workflows/ before schedule/dispatch runs."
170+
"workflow_evidence_policy_zh": "github_workflow_allowlist 仅用于运维定位与存在性辅助证据。workflow success ≠ 成交/订单事实;PAPER once ≠ live;正式业务证据优先 DIGEST_CANDIDATES。heartbeat-only 永不进入 allowlist。",
171+
"workflow_evidence_policy_en": "github_workflow_allowlist is ops locator + existence-only auxiliary evidence. workflow success ≠ fill/order facts; PAPER once ≠ live; prefer DIGEST_CANDIDATES for business evidence. Heartbeat-only never enters the allowlist."
148172
}
149173
}
150174
}

‎python/scripts/build_config.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,56 @@ def validate_notification_references(config: dict, errors: list[str]) -> None:
743743
f"github_workflow_allowlist[{index}].{field} "
744744
"required"
745745
)
746+
evidence_role = item.get("evidence_role")
747+
if evidence_role is not None and not str(evidence_role).strip():
748+
errors.append(
749+
f"{path}.daily_digest.aggregator."
750+
f"github_workflow_allowlist[{index}].evidence_role "
751+
"must be non-empty when set"
752+
)
753+
path_status = item.get("path_status")
754+
if path_status is not None and str(path_status).strip() not in {
755+
"present",
756+
}:
757+
errors.append(
758+
f"{path}.daily_digest.aggregator."
759+
f"github_workflow_allowlist[{index}].path_status "
760+
"must be 'present' when set (404 paths belong in "
761+
"github_workflow_allowlist_omitted)"
762+
)
763+
omitted = aggregator.get("github_workflow_allowlist_omitted")
764+
if omitted is not None:
765+
if not isinstance(omitted, list):
766+
errors.append(
767+
f"{path}.daily_digest.aggregator."
768+
"github_workflow_allowlist_omitted must be a list"
769+
)
770+
else:
771+
for index, item in enumerate(omitted):
772+
if not isinstance(item, dict):
773+
errors.append(
774+
f"{path}.daily_digest.aggregator."
775+
f"github_workflow_allowlist_omitted[{index}] "
776+
"must be an object"
777+
)
778+
continue
779+
if not str(item.get("platform_id") or "").strip():
780+
errors.append(
781+
f"{path}.daily_digest.aggregator."
782+
f"github_workflow_allowlist_omitted[{index}]."
783+
"platform_id required"
784+
)
785+
status = str(item.get("path_status") or "").strip()
786+
if status not in {
787+
"missing",
788+
"no_daily_projection_workflow",
789+
}:
790+
errors.append(
791+
f"{path}.daily_digest.aggregator."
792+
f"github_workflow_allowlist_omitted[{index}]."
793+
"path_status must be 'missing' or "
794+
"'no_daily_projection_workflow'"
795+
)
746796

747797

748798
def validate_runtime_authority_status(config: dict, errors: list[str]) -> None:

‎python/scripts/daily_digest_aggregator.py‎

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -196,26 +196,19 @@ def default_workflow_allowlist(config: Mapping[str, Any]) -> list[dict[str, str]
196196

197197
platforms = config.get("platforms") or {}
198198
rows: list[dict[str, str]] = []
199-
defaults: dict[str, tuple[str, str]] = {
199+
# Fallback only when platform-config allowlist is absent.
200+
# Keep in sync with calibrated present paths (P0-04); never list 404 stems.
201+
defaults: dict[str, tuple[str, str, str]] = {
202+
# workflow → strategy_profile → evidence_role
200203
"longbridge": (
201204
"publish-runtime-daily-once.yml",
202205
"russell_top50_leader_rotation",
206+
"paper_projection_once",
203207
),
204208
"schwab": (
205-
"publish-runtime-daily-once.yml",
209+
"runtime-daily-sync.yml",
206210
"soxl_soxx_trend_income",
207-
),
208-
"ibkr": (
209-
"publish-runtime-daily-once.yml",
210-
"soxl_soxx_trend_income",
211-
),
212-
"firstrade": (
213-
"publish-runtime-daily-once.yml",
214-
"unspecified",
215-
),
216-
"binance": (
217-
"publish-runtime-daily-once.yml",
218-
"crypto_equity_combo",
211+
"manual_daily_projection",
219212
),
220213
}
221214
configured = (
@@ -239,17 +232,22 @@ def default_workflow_allowlist(config: Mapping[str, Any]) -> list[dict[str, str]
239232
repo = str(platforms[platform_id].get("repository") or "").strip()
240233
if not repo:
241234
continue
242-
rows.append(
243-
{
244-
"platform_id": platform_id,
245-
"repository": repo,
246-
"workflow": workflow,
247-
"strategy_profile": strategy or "unspecified",
248-
}
249-
)
235+
row = {
236+
"platform_id": platform_id,
237+
"repository": repo,
238+
"workflow": workflow,
239+
"strategy_profile": strategy or "unspecified",
240+
}
241+
evidence_role = str(item.get("evidence_role") or "").strip()
242+
if evidence_role:
243+
row["evidence_role"] = evidence_role
244+
path_status = str(item.get("path_status") or "").strip()
245+
if path_status:
246+
row["path_status"] = path_status
247+
rows.append(row)
250248
return rows
251249

252-
for platform_id, (workflow, strategy) in defaults.items():
250+
for platform_id, (workflow, strategy, evidence_role) in defaults.items():
253251
meta = platforms.get(platform_id) or {}
254252
repo = str(meta.get("repository") or "").strip()
255253
if not repo:
@@ -260,6 +258,8 @@ def default_workflow_allowlist(config: Mapping[str, Any]) -> list[dict[str, str]
260258
"repository": repo,
261259
"workflow": workflow,
262260
"strategy_profile": strategy,
261+
"evidence_role": evidence_role,
262+
"path_status": "present",
263263
}
264264
)
265265
return rows
@@ -305,6 +305,7 @@ def collect_from_github_workflows(
305305
repository = entry["repository"]
306306
workflow = entry["workflow"]
307307
strategy = entry["strategy_profile"]
308+
evidence_role = str(entry.get("evidence_role") or "").strip()
308309
owner_repo = repository.split("/", 1)
309310
if len(owner_repo) != 2:
310311
result.failures.append(
@@ -387,7 +388,10 @@ def collect_from_github_workflows(
387388
"order_count": None,
388389
"cycle_count": 1,
389390
"status": "ok",
390-
"note": f"github_workflow:{workflow}",
391+
"note": (
392+
f"github_workflow:{workflow}"
393+
+ (f";evidence_role:{evidence_role}" if evidence_role else "")
394+
),
391395
"field_status": {
392396
"fill_count": "counts_unknown",
393397
"order_count": "counts_unknown",

‎python/tests/test_daily_digest_aggregator.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,50 @@ def test_allowlist_loaded_from_platform_config(self):
184184
self.assertTrue(any(row["platform_id"] == "longbridge" for row in rows))
185185
self.assertTrue(all(row["repository"].startswith("QuantStrategyLab/") for row in rows))
186186

187+
def test_allowlist_calibrated_paths_and_roles(self):
188+
config = aggregator.load_platform_config()
189+
rows = aggregator.default_workflow_allowlist(config)
190+
by_platform = {row["platform_id"]: row for row in rows}
191+
self.assertEqual(set(by_platform), {"longbridge", "schwab"})
192+
self.assertEqual(
193+
by_platform["longbridge"]["workflow"], "publish-runtime-daily-once.yml"
194+
)
195+
self.assertEqual(
196+
by_platform["longbridge"].get("evidence_role"), "paper_projection_once"
197+
)
198+
self.assertEqual(by_platform["schwab"]["workflow"], "runtime-daily-sync.yml")
199+
self.assertEqual(
200+
by_platform["schwab"].get("evidence_role"), "manual_daily_projection"
201+
)
202+
# 404 / heartbeat-only platforms stay out of the active allowlist.
203+
for absent in ("ibkr", "binance", "firstrade"):
204+
self.assertNotIn(absent, by_platform)
205+
omitted = (
206+
(config.get("notifications") or {})
207+
.get("quant_sentinel", {})
208+
.get("daily_digest", {})
209+
.get("aggregator", {})
210+
.get("github_workflow_allowlist_omitted")
211+
or []
212+
)
213+
omitted_ids = {str(item.get("platform_id")) for item in omitted}
214+
self.assertTrue({"ibkr", "binance", "firstrade"}.issubset(omitted_ids))
215+
216+
def test_allowlist_fallback_defaults_match_calibrated_paths(self):
217+
config = {
218+
"platforms": {
219+
"longbridge": {"repository": "QuantStrategyLab/LongBridgePlatform"},
220+
"schwab": {"repository": "QuantStrategyLab/CharlesSchwabPlatform"},
221+
"ibkr": {"repository": "QuantStrategyLab/InteractiveBrokersPlatform"},
222+
},
223+
"notifications": {"quant_sentinel": {"daily_digest": {"aggregator": {}}}},
224+
}
225+
rows = aggregator.default_workflow_allowlist(config)
226+
by_platform = {row["platform_id"]: row for row in rows}
227+
self.assertEqual(set(by_platform), {"longbridge", "schwab"})
228+
self.assertEqual(by_platform["schwab"]["workflow"], "runtime-daily-sync.yml")
229+
self.assertNotIn("ibkr", by_platform)
230+
187231
def test_sender_dry_run_does_not_require_secrets(self):
188232
code = sender.main(
189233
[

‎python/tests/test_runtime_settings.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,23 @@ def test_notification_route_is_runtime_reference_only(self):
315315
self.assertEqual(aggregator["schema_version"], "qsl.daily_digest_aggregator.v1")
316316
self.assertEqual(aggregator["counts_policy"], "never_invent_fills_or_orders")
317317
self.assertTrue(aggregator["github_workflow_allowlist"])
318+
allow_by_platform = {
319+
row["platform_id"]: row for row in aggregator["github_workflow_allowlist"]
320+
}
321+
self.assertEqual(
322+
allow_by_platform["longbridge"]["workflow"],
323+
"publish-runtime-daily-once.yml",
324+
)
325+
self.assertEqual(
326+
allow_by_platform["schwab"]["workflow"],
327+
"runtime-daily-sync.yml",
328+
)
329+
self.assertNotIn("ibkr", allow_by_platform)
330+
self.assertNotIn("binance", allow_by_platform)
331+
omitted_ids = {
332+
row["platform_id"] for row in aggregator.get("github_workflow_allowlist_omitted") or []
333+
}
334+
self.assertTrue({"ibkr", "binance", "firstrade"}.issubset(omitted_ids))
318335
self.assertEqual(
319336
sentinel["github_variable_contract"]["TELEGRAM_TOKEN_SECRET_NAME"],
320337
"quant-sentinel-telegram-bot-token",

0 commit comments

Comments
 (0)