Skip to content

Commit b328629

Browse files
Pigbibicodex
andauthored
fix(console): separate catalog gates from runtime facts (#288)
Co-authored-by: Codex <noreply@openai.com>
1 parent 361341c commit b328629

12 files changed

Lines changed: 1042 additions & 18 deletions

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
- ".github/workflows/deploy-strategy-switch-console.yml"
88
- "platform-config.json"
99
- "python/scripts/build_config.py"
10+
- "python/scripts/build_platform_config.py"
1011
- "python/scripts/sync_strategy_switch_page_asset.py"
1112
- "web/strategy-switch-console/**"
1213
workflow_dispatch:
@@ -58,6 +59,7 @@ jobs:
5859
run: |
5960
set -euo pipefail
6061
python3 python/scripts/build_platform_config.py
62+
python3 python/scripts/build_platform_config.py --check
6163
python3 python/scripts/inject_platform_config.py
6264
python3 python/scripts/sync_strategy_switch_page_asset.py
6365

‎.github/workflows/platform-health-monitor.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@ jobs:
5757
run: |
5858
set -euo pipefail
5959
python3 python/scripts/build_config.py
60+
python3 python/scripts/build_platform_config.py --check
6061
python3 python/scripts/sync_strategy_switch_page_asset.py
6162
git diff --exit-code -- \
6263
web/strategy-switch-console/config.js \
6364
web/strategy-switch-console/index.html \
6465
web/strategy-switch-console/page_asset.js \
6566
web/strategy-switch-console/strategy_profiles_asset.js \
67+
web/strategy-switch-console/runtime-catalog-projection.json \
6668
web/strategy-switch-console/app_css.js \
6769
web/strategy-switch-console/app_js.js
6870
jq empty web/strategy-switch-console/strategy-profiles.example.json

‎.github/workflows/validate.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ jobs:
9191
set -euo pipefail
9292
python3 python/scripts/build_config.py --check
9393
python3 python/scripts/build_config.py
94+
python3 python/scripts/build_platform_config.py --check
9495
python3 python/scripts/sync_strategy_switch_page_asset.py
95-
git diff --exit-code -- web/strategy-switch-console/config.js web/strategy-switch-console/index.html web/strategy-switch-console/page_asset.js web/strategy-switch-console/strategy_profiles_asset.js web/strategy-switch-console/app_css.js web/strategy-switch-console/app_js.js
96+
git diff --exit-code -- web/strategy-switch-console/config.js web/strategy-switch-console/index.html web/strategy-switch-console/page_asset.js web/strategy-switch-console/strategy_profiles_asset.js web/strategy-switch-console/runtime-catalog-projection.json web/strategy-switch-console/app_css.js web/strategy-switch-console/app_js.js
9697
jq empty web/strategy-switch-console/strategy-profiles.example.json
9798
node --experimental-default-type=module tests/strategy_switch_worker_validation.mjs
9899
sed -n '/<script>/,/<\/script>/p' web/strategy-switch-console/index.html | sed '1d;$d' | node --check --input-type=commonjs

‎docs/qsl_lifecycle_truth_v1.zh-CN.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ blocked_live_reason is empty
3535
缺少任意字段时都 fail closed。设置网站、Worker、配置生成器和后端验证不得从
3636
catalog 名称、默认值或 inventory 状态推导 live 权限。
3737

38+
## 控制台读取优先级与陈旧资料保护
39+
40+
`web/strategy-switch-console/runtime-catalog-projection.json` 由
41+
`platform-config.json` 生成,并带来源内容 SHA-256。它只显示目录门禁(例如策略是否
42+
可被切换流程考虑),`data_status=catalog_only`;它不观察 Cloud Run、Gateway、账户、
43+
订单、资金或 P4–P6 收据,因此不能用作“正在运行”或“已可升级”的事实。
44+
45+
控制台必须按用途读取独立来源:
46+
47+
1. 候选 P1–P3 生命周期与新鲜度:`GET /api/control-plane`;
48+
2. 精确策略 × 平台 × 通道的执行证据:`GET /api/execution-evidence`;
49+
3. 配置目录门禁:登录后的 `GET /api/runtime-catalog`。
50+
51+
`web/strategy-switch-console/lifecycle-matrix.json` 只保留为 2026-08-23 的历史参考,
52+
已标记 `historical_reference_only`。任何界面、自动化或人工审阅都不得把它作为当前
53+
运行、升级或下单依据;缺少新鲜快照时应显示 `unavailable` / `stale`,不能回退到该
54+
历史矩阵填充“正常”状态。
55+
3856
## 一次性迁移规则
3957

4058
- `platform-config.json`、设置网站 fallback 和生成的 profile asset 只写规范状态。

‎python/scripts/build_platform_config.py‎

Lines changed: 111 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from __future__ import annotations
1313

14+
import argparse
15+
import hashlib
1416
import json
1517
from pathlib import Path
1618

@@ -19,6 +21,8 @@
1921
TARGET = ROOT / "web" / "strategy-switch-console" / "config.js"
2022
STRATEGY_TARGET = ROOT / "web" / "strategy-switch-console" / "strategy_profiles_asset.js"
2123
STRATEGY_EXAMPLE_TARGET = ROOT / "web" / "strategy-switch-console" / "strategy-profiles.example.json"
24+
RUNTIME_CATALOG_PROJECTION_TARGET = ROOT / "web" / "strategy-switch-console" / "runtime-catalog-projection.json"
25+
RUNTIME_CATALOG_PROJECTION_SCHEMA_VERSION = "qsl.runtime_catalog_projection.v1"
2226

2327

2428
def build_config_module(config: dict) -> str:
@@ -27,6 +31,7 @@ def build_config_module(config: dict) -> str:
2731
domains = config.get("domains", {})
2832
meta = config.get("meta", {})
2933
runtime_authority = meta.get("runtime_authority", {}) if isinstance(meta, dict) else {}
34+
runtime_catalog_projection = build_runtime_catalog_projection(config)
3035

3136
# ── platformConfig (replaces hardcoded in index.html) ──
3237
platform_config = {}
@@ -172,6 +177,8 @@ def build_config_module(config: dict) -> str:
172177
"",
173178
f"export const RUNTIME_AUTHORITY_STATUS = {json.dumps(runtime_authority, indent=2, ensure_ascii=False)};",
174179
"",
180+
f"export const RUNTIME_CATALOG_PROJECTION = {json.dumps(runtime_catalog_projection, indent=2, ensure_ascii=False)};",
181+
"",
175182
f"export const DEFAULT_ACCOUNT_OPTIONS = {json.dumps(default_accounts, indent=2, ensure_ascii=False)};",
176183
"",
177184
f"export const PLATFORM_REPOSITORIES = {json.dumps(repositories, indent=2, ensure_ascii=False)};",
@@ -293,28 +300,118 @@ def _strategy_profile_gate_fields(sdata: dict) -> dict[str, object]:
293300
}
294301

295302

296-
def main() -> int:
297-
config = json.loads(SOURCE.read_text(encoding="utf-8"))
303+
def _config_content_sha256(config: dict) -> str:
304+
canonical = json.dumps(config, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
305+
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
298306

299-
# Generate config.js
300-
module = build_config_module(config)
301-
TARGET.write_text(module, encoding="utf-8")
302307

303-
# Generate strategy_profiles_asset.js and keep strategy-profiles.example.json aligned.
308+
def build_runtime_catalog_projection(config: dict) -> dict[str, object]:
309+
"""Project catalog gates without claiming an observed runtime state.
310+
311+
This is deliberately separate from control-plane and execution-evidence
312+
snapshots. A checked-in strategy catalog can describe which modes a
313+
workflow may consider, but cannot prove that a target is deployed, healthy,
314+
funded, or permitted to trade.
315+
"""
304316
profiles = build_strategy_profile_entries(config)
305-
STRATEGY_TARGET.write_text(
306-
"// Generated by python/scripts/build_platform_config.py from platform-config.json\n"
307-
f"export const DEFAULT_STRATEGY_PROFILES = {json.dumps(profiles, indent=2, ensure_ascii=False)};\n",
308-
encoding="utf-8",
309-
)
310-
STRATEGY_EXAMPLE_TARGET.write_text(
311-
json.dumps(profiles, ensure_ascii=False, separators=(",", ":")) + "\n",
312-
encoding="utf-8",
317+
status_profiles = [
318+
{
319+
field: profile[field]
320+
for field in (
321+
"profile",
322+
"label",
323+
"label_en",
324+
"label_zh",
325+
"domain",
326+
"lifecycle_stage",
327+
"runtime_enabled",
328+
"can_switch_live",
329+
"allowed_execution_modes",
330+
"blocked_live_reason",
331+
)
332+
}
333+
for profile in profiles
334+
]
335+
lifecycle_stage_counts: dict[str, int] = {}
336+
for profile in status_profiles:
337+
stage = str(profile["lifecycle_stage"])
338+
lifecycle_stage_counts[stage] = lifecycle_stage_counts.get(stage, 0) + 1
339+
340+
meta = config.get("meta", {})
341+
return {
342+
"schema_version": RUNTIME_CATALOG_PROJECTION_SCHEMA_VERSION,
343+
"data_status": "catalog_only",
344+
"source": {
345+
"path": "platform-config.json",
346+
"content_sha256": _config_content_sha256(config),
347+
"catalog_as_of": meta.get("last_updated") if isinstance(meta, dict) else None,
348+
},
349+
"policy": {
350+
"catalog_is_runtime_observation": False,
351+
"catalog_can_authorize_promotion_or_trading": False,
352+
"historical_lifecycle_inventory_is_authoritative": False,
353+
"observed_state_sources": [
354+
"qsl_control_plane_dashboard.v1",
355+
"qsl_execution_evidence_dashboard.v1",
356+
],
357+
},
358+
"runtime_authority": meta.get("runtime_authority", {}) if isinstance(meta, dict) else {},
359+
"summary": {
360+
"strategy_profile_count": len(status_profiles),
361+
"runtime_enabled_count": sum(1 for profile in status_profiles if profile["runtime_enabled"]),
362+
"live_switchable_count": sum(1 for profile in status_profiles if profile["can_switch_live"]),
363+
"lifecycle_stage_counts": dict(sorted(lifecycle_stage_counts.items())),
364+
},
365+
"strategies": status_profiles,
366+
}
367+
368+
369+
def _generated_outputs(config: dict) -> dict[Path, str]:
370+
profiles = build_strategy_profile_entries(config)
371+
projection = build_runtime_catalog_projection(config)
372+
return {
373+
TARGET: build_config_module(config),
374+
STRATEGY_TARGET: (
375+
"// Generated by python/scripts/build_platform_config.py from platform-config.json\n"
376+
f"export const DEFAULT_STRATEGY_PROFILES = {json.dumps(profiles, indent=2, ensure_ascii=False)};\n"
377+
),
378+
STRATEGY_EXAMPLE_TARGET: json.dumps(profiles, ensure_ascii=False, separators=(",", ":")) + "\n",
379+
RUNTIME_CATALOG_PROJECTION_TARGET: json.dumps(projection, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
380+
}
381+
382+
383+
def main() -> int:
384+
parser = argparse.ArgumentParser(description="Generate strategy-switch console assets from platform-config.json")
385+
parser.add_argument(
386+
"--check",
387+
action="store_true",
388+
help="Fail when the generated runtime catalog projection does not match platform-config.json",
313389
)
390+
args = parser.parse_args()
391+
config = json.loads(SOURCE.read_text(encoding="utf-8"))
392+
outputs = _generated_outputs(config)
393+
394+
if args.check:
395+
# sync_strategy_switch_page_asset.py intentionally renders the strategy
396+
# JS asset with a different (but semantically equivalent) formatter.
397+
# The projection has one generator, so it is safe to use as a strict
398+
# source-freshness guard even after the full console build pipeline.
399+
checkable = {RUNTIME_CATALOG_PROJECTION_TARGET: outputs[RUNTIME_CATALOG_PROJECTION_TARGET]}
400+
stale = [path for path, expected in checkable.items() if not path.exists() or path.read_text(encoding="utf-8") != expected]
401+
if stale:
402+
for path in stale:
403+
print(f"Generated asset is stale: {path.relative_to(ROOT)}")
404+
return 1
405+
print("Generated runtime catalog projection matches platform-config.json")
406+
return 0
407+
408+
for path, content in outputs.items():
409+
path.write_text(content, encoding="utf-8")
314410

315411
print(f"Generated: {TARGET}")
316412
print(f"Generated: {STRATEGY_TARGET}")
317413
print(f"Generated: {STRATEGY_EXAMPLE_TARGET}")
414+
print(f"Generated: {RUNTIME_CATALOG_PROJECTION_TARGET}")
318415
return 0
319416

320417

‎python/tests/test_runtime_settings.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,38 @@ def test_build_config_strategy_to_json_compat_includes_strategy_gate_fields(self
714714
self.assertEqual(profile["allowed_execution_modes"], ["paper", "dry_run"])
715715
self.assertEqual(profile["blocked_live_reason"], "manual-review")
716716

717+
def test_runtime_catalog_projection_is_exact_and_never_claims_observed_runtime(self):
718+
config = build_config.load_config()
719+
projection_path = ROOT / "web" / "strategy-switch-console" / "runtime-catalog-projection.json"
720+
projection = json.loads(projection_path.read_text(encoding="utf-8"))
721+
722+
self.assertEqual(projection, build_platform_config.build_runtime_catalog_projection(config))
723+
self.assertEqual(projection["schema_version"], "qsl.runtime_catalog_projection.v1")
724+
self.assertEqual(projection["data_status"], "catalog_only")
725+
self.assertFalse(projection["policy"]["catalog_is_runtime_observation"])
726+
self.assertFalse(projection["policy"]["catalog_can_authorize_promotion_or_trading"])
727+
self.assertFalse(projection["policy"]["historical_lifecycle_inventory_is_authoritative"])
728+
self.assertEqual(
729+
projection["summary"]["strategy_profile_count"],
730+
len(config["strategies"]),
731+
)
732+
self.assertEqual(projection["summary"]["live_switchable_count"], 0)
733+
self.assertEqual(
734+
projection["source"]["content_sha256"],
735+
build_platform_config._config_content_sha256(config),
736+
)
737+
738+
def test_historical_lifecycle_inventory_is_explicitly_non_authoritative(self):
739+
matrix = json.loads(
740+
(ROOT / "web" / "strategy-switch-console" / "lifecycle-matrix.json").read_text(encoding="utf-8")
741+
)
742+
743+
self.assertEqual(matrix["schema_version"], "qsl.historical_lifecycle_inventory.v1")
744+
self.assertEqual(matrix["record_status"], "historical_reference_only")
745+
self.assertEqual(matrix["superseded_by"]["catalog_gates"], "runtime-catalog-projection.json")
746+
self.assertEqual(matrix["superseded_by"]["candidate_lifecycle"], "GET /api/control-plane")
747+
self.assertEqual(matrix["superseded_by"]["target_execution_evidence"], "GET /api/execution-evidence")
748+
717749
def test_global_and_hk_global_etf_rotation_profiles_are_research_only(self):
718750
expected = {
719751
"lifecycle_stage": "research_active",

‎tests/strategy_switch_worker_validation.mjs‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
44
import { dirname, resolve } from "node:path";
55

66
import worker, { __test } from "../web/strategy-switch-console/worker.js";
7-
import { DEFAULT_ACCOUNT_OPTIONS } from "../web/strategy-switch-console/config.js";
7+
import { DEFAULT_ACCOUNT_OPTIONS, RUNTIME_CATALOG_PROJECTION } from "../web/strategy-switch-console/config.js";
88

99
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
1010
const indexHtml = [
@@ -1890,6 +1890,21 @@ const noKvPayload = await noKvHealthRead.json();
18901890
assert.equal(noKvPayload.data_status, "unavailable");
18911891
assert.equal(noKvPayload.summary.strategy_count, 0);
18921892

1893+
const unauthorizedRuntimeCatalogRead = await worker.fetch(
1894+
new Request("https://switch.example/api/runtime-catalog"),
1895+
healthEnv,
1896+
);
1897+
assert.equal(unauthorizedRuntimeCatalogRead.status, 401);
1898+
const runtimeCatalogRead = await worker.fetch(
1899+
new Request("https://switch.example/api/runtime-catalog", { headers: healthCookieHeaders }),
1900+
healthEnv,
1901+
);
1902+
assert.equal(runtimeCatalogRead.status, 200);
1903+
assert.deepEqual(await runtimeCatalogRead.json(), RUNTIME_CATALOG_PROJECTION);
1904+
assert.equal(RUNTIME_CATALOG_PROJECTION.data_status, "catalog_only");
1905+
assert.equal(RUNTIME_CATALOG_PROJECTION.policy.catalog_is_runtime_observation, false);
1906+
assert.equal(RUNTIME_CATALOG_PROJECTION.policy.catalog_can_authorize_promotion_or_trading, false);
1907+
18931908
const controlStore = new Map();
18941909
const controlKv = {
18951910
async get(key) { return controlStore.get(key) || null; },

‎web/strategy-switch-console/README.zh-CN.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ python3 scripts/sync_strategy_switch_page_asset.py
101101

102102
这会重新生成 `web/strategy-switch-console/page_asset.js` 和 `web/strategy-switch-console/strategy_profiles_asset.js`。部署 Worker 时需要同时带上 `worker.js`、`page_asset.js` 和 `strategy_profiles_asset.js`。
103103

104+
`runtime-catalog-projection.json` 同样由 `platform-config.json` 生成,并用来源 SHA-256
105+
防止已提交的目录资产陈旧。登录后的 `GET /api/runtime-catalog` 只返回这个**配置门禁**
106+
投影;它不代表真实部署或交易状态。页面显示候选状态和目标执行状态时,必须分别使用
107+
`/api/control-plane` 与 `/api/execution-evidence`,不得回退到历史 `lifecycle-matrix.json`。
108+
104109
## 账号下拉配置
105110

106111
Worker 页面内置示例 target 作为兜底。登录后如果没有加载账号配置,“一键切换”仍会保持禁用,Worker 后端也会拒绝 dispatch,避免账号不匹配。复制示例文件后填入你的真实 target/account route:

0 commit comments

Comments
 (0)