Skip to content

Commit 734e6de

Browse files
Pigbibicodex
andauthored
feat: record owner decisions in control console (#285)
Co-authored-by: Codex <noreply@openai.com>
1 parent 5778acd commit 734e6de

12 files changed

Lines changed: 672 additions & 30 deletions

docs/qsl_unified_control_console_architecture_v1.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Worker 以**独立于 dispatch token 的同步身份**接收来源快照、按 `
6262

6363
- P1–P3 的异常显示为机器可处理的 `DEFERRED` / `PARKED`,下一有效交易日可重试;一次数据源中断不应作废整年研究。
6464
- P4/P5 只显示预先写入策略的自动评估状态,不显示“AI 立即改参”按钮。
65-
- 只有阶段为 P6、状态为 `owner_decision_required` 且建议为 `owner_live_decision` 的候选才进入所有者队列。人可选择确认、保持暂停或退役候选;页面先创建审计化的**决策意图**不直接调用券商。
65+
- 只有阶段为 P6、状态为 `owner_decision_required` 且建议为 `owner_live_decision` 的候选才进入所有者队列。人可选择确认受限试运行意图、保持暂停或退役候选;页面创建并校验 `qsl_owner_decision_intent.v1`,绑定当前候选证据指纹,不直接调用券商。证据、候选或阶段变化后,旧意图不会匹配,必须重新决定
6666

6767
### 3. P6 决策适配器(最后做)
6868

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://quantstrategylab.github.io/QuantRuntimeSettings/schemas/qsl-owner-decision-intent.v1.schema.json",
4+
"title": "QSL Owner Decision Intent v1",
5+
"description": "A signed-by-digest, no-order owner decision intent. It is not execution authority.",
6+
"type": "object",
7+
"additionalProperties": false,
8+
"required": [
9+
"schema_version",
10+
"candidate_id",
11+
"candidate_kind",
12+
"domain",
13+
"decision",
14+
"decided_at",
15+
"decided_by",
16+
"candidate_evidence_sha256",
17+
"no_order",
18+
"execution_authority_granted",
19+
"decision_sha256"
20+
],
21+
"properties": {
22+
"schema_version": { "const": "qsl_owner_decision_intent.v1" },
23+
"candidate_id": { "type": "string", "pattern": "^[A-Za-z0-9._=-]{1,128}$" },
24+
"candidate_kind": { "enum": ["individual", "portfolio", "plugin"] },
25+
"domain": { "enum": ["us_equity", "hk_equity", "cn_equity", "crypto"] },
26+
"decision": { "enum": ["approve_limited_live_canary", "keep_parked", "retire_candidate"] },
27+
"decided_at": { "type": "string", "format": "date-time", "maxLength": 64 },
28+
"decided_by": { "type": "string", "pattern": "^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$" },
29+
"candidate_evidence_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
30+
"no_order": { "const": true },
31+
"execution_authority_granted": { "const": false },
32+
"decision_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
33+
}
34+
}

tests/strategy_switch_worker_validation.mjs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2057,6 +2057,118 @@ assert.deepEqual(sourceControlPayload.summary, { candidate_count: 1, deferred: 0
20572057
assert.deepEqual(sourceControlPayload.attention, { status: "research_only", reason_codes: [] });
20582058
assert.equal(sourceControlPayload.candidates[0].candidate_id, "tqqq_core_only_p2_v5");
20592059

2060+
const ownerDecisionStore = new Map();
2061+
const ownerDecisionKv = {
2062+
async get(key) { return ownerDecisionStore.get(key) || null; },
2063+
async put(key, value) { ownerDecisionStore.set(key, value); },
2064+
async list({ prefix = "", limit = 1000 } = {}) {
2065+
return {
2066+
keys: [...ownerDecisionStore.keys()]
2067+
.filter((key) => key.startsWith(prefix))
2068+
.slice(0, limit)
2069+
.map((name) => ({ name })),
2070+
};
2071+
},
2072+
};
2073+
const ownerDecisionEnv = {
2074+
...controlEnv,
2075+
STRATEGY_SWITCH_CONFIG: ownerDecisionKv,
2076+
ALLOWED_GITHUB_LOGINS: "owner-admin,owner-reader",
2077+
STRATEGY_SWITCH_ADMIN_LOGINS: "owner-admin",
2078+
};
2079+
const ownerAdminCookie = await __test.makeSession("owner-admin", [], ownerDecisionEnv);
2080+
const ownerReaderCookie = await __test.makeSession("owner-reader", [], ownerDecisionEnv);
2081+
const ownerAdminHeaders = { Cookie: `qsl_switch_session=${ownerAdminCookie}` };
2082+
const ownerReaderHeaders = { Cookie: `qsl_switch_session=${ownerReaderCookie}` };
2083+
const ownerDecisionPayload = {
2084+
...controlPayload,
2085+
generated_at: controlNow,
2086+
computed_at: controlNow,
2087+
candidates: [{
2088+
...controlPayload.candidates[0],
2089+
candidate_id: "soxl_core_only_p2_v7",
2090+
lifecycle: { stage: "P6", status: "owner_decision_required" },
2091+
evidence: {
2092+
p1_input_digest: "1".repeat(64),
2093+
p2_config_digest: "2".repeat(64),
2094+
p3_evidence_id: "3".repeat(64),
2095+
source_revision: "4".repeat(40),
2096+
},
2097+
recommendation: { code: "owner_live_decision", reason: "P4/P5 evidence is current; owner decision required." },
2098+
freshness: { status: "fresh", age_seconds: 0 },
2099+
}],
2100+
};
2101+
const ownerDecisionSync = await worker.fetch(
2102+
new Request("https://switch.example/api/internal/sync-control-plane", {
2103+
method: "POST",
2104+
headers: { Authorization: `Bearer ${controlSyncValue}`, "Content-Type": "application/json" },
2105+
body: JSON.stringify(ownerDecisionPayload),
2106+
}),
2107+
ownerDecisionEnv,
2108+
);
2109+
assert.equal(ownerDecisionSync.status, 200);
2110+
2111+
const ownerDecisionQueue = await worker.fetch(
2112+
new Request("https://switch.example/api/owner-decisions", { headers: ownerAdminHeaders }),
2113+
ownerDecisionEnv,
2114+
);
2115+
assert.equal(ownerDecisionQueue.status, 200);
2116+
const ownerDecisionQueuePayload = await ownerDecisionQueue.json();
2117+
assert.equal(ownerDecisionQueuePayload.data_status, "ready");
2118+
assert.equal(ownerDecisionQueuePayload.candidates.length, 1);
2119+
assert.equal(ownerDecisionQueuePayload.candidates[0].intent, null);
2120+
assert.equal(ownerDecisionQueuePayload.policy.execution_authority_granted, false);
2121+
const ownerDecisionRequest = {
2122+
candidate_id: "soxl_core_only_p2_v7",
2123+
decision: "keep_parked",
2124+
candidate_evidence_sha256: ownerDecisionQueuePayload.candidates[0].candidate_evidence_sha256,
2125+
};
2126+
2127+
const readerDecision = await worker.fetch(
2128+
new Request("https://switch.example/api/owner-decisions", {
2129+
method: "POST",
2130+
headers: { ...ownerReaderHeaders, Origin: "https://switch.example", "Content-Type": "application/json" },
2131+
body: JSON.stringify(ownerDecisionRequest),
2132+
}),
2133+
ownerDecisionEnv,
2134+
);
2135+
assert.equal(readerDecision.status, 403);
2136+
2137+
const ownerDecisionWrite = await worker.fetch(
2138+
new Request("https://switch.example/api/owner-decisions", {
2139+
method: "POST",
2140+
headers: { ...ownerAdminHeaders, Origin: "https://switch.example", "Content-Type": "application/json" },
2141+
body: JSON.stringify(ownerDecisionRequest),
2142+
}),
2143+
ownerDecisionEnv,
2144+
);
2145+
assert.equal(ownerDecisionWrite.status, 200);
2146+
const ownerDecisionWritePayload = await ownerDecisionWrite.json();
2147+
assert.equal(ownerDecisionWritePayload.intent.decision, "keep_parked");
2148+
assert.equal(ownerDecisionWritePayload.intent.no_order, true);
2149+
assert.equal(ownerDecisionWritePayload.intent.execution_authority_granted, false);
2150+
assert.ok(ownerDecisionStore.has("owner_decision_current:soxl_core_only_p2_v7"));
2151+
assert.ok(ownerDecisionStore.has(
2152+
`owner_decision_intent:soxl_core_only_p2_v7:${ownerDecisionWritePayload.intent.decision_sha256}`,
2153+
));
2154+
2155+
const recordedOwnerDecisionQueue = await worker.fetch(
2156+
new Request("https://switch.example/api/owner-decisions", { headers: ownerAdminHeaders }),
2157+
ownerDecisionEnv,
2158+
);
2159+
const recordedOwnerDecisionPayload = await recordedOwnerDecisionQueue.json();
2160+
assert.equal(recordedOwnerDecisionPayload.candidates[0].intent.decision, "keep_parked");
2161+
2162+
const staleDecision = await worker.fetch(
2163+
new Request("https://switch.example/api/owner-decisions", {
2164+
method: "POST",
2165+
headers: { ...ownerAdminHeaders, Origin: "https://switch.example", "Content-Type": "application/json" },
2166+
body: JSON.stringify({ ...ownerDecisionRequest, candidate_evidence_sha256: "0".repeat(64) }),
2167+
}),
2168+
ownerDecisionEnv,
2169+
);
2170+
assert.equal(staleDecision.status, 409);
2171+
20602172
const conflictingSourceSync = await worker.fetch(
20612173
new Request("https://switch.example/api/internal/sync-control-plane-source", {
20622174
method: "POST",

web/strategy-switch-console/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ audit_log
6868

6969
Without the KV binding, `/admin` is read-only and the Worker falls back to `ALLOWED_GITHUB_LOGINS`, `ALLOWED_GITHUB_ORGS`, `STRATEGY_SWITCH_ADMIN_LOGINS`, `STRATEGY_SWITCH_ADMIN_ORGS`, and `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`.
7070

71+
## Web Owner Decisions (P6 intent)
72+
73+
Only a fresh P6 candidate with `owner_decision_required` and an `owner_live_decision` recommendation appears in the owner-decision area. Console administrators can record one of three choices: approve a limited-canary intent, keep the candidate parked, or retire it.
74+
75+
`POST /api/owner-decisions` stores a `qsl_owner_decision_intent.v1` bound to the current P1/P2/P3/revision evidence fingerprint. It retains an immutable, SHA-256-addressed KV record, a current-record index, and an audit entry. If the candidate, stage, or evidence changes, the old intent no longer matches the queue and the owner must decide again.
76+
77+
This is not an execution API: every intent is fixed as `no_order=true` and `execution_authority_granted=false`. It does not dispatch a workflow, call a platform or broker, change funds, or enable Live. Only a future independent deterministic execution gateway may consume an intent after it verifies all current P4/P5/P6 conditions.
78+
7179
## Page Asset
7280

7381
`worker.js` serves `web/strategy-switch-console/index.html` through `page_asset.js` and the fallback live-enabled strategy catalog through `strategy_profiles_asset.js`.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,14 @@ GET /api/control-plane
176176

177177
这个接口不是订单、paper、shadow 或 live API。`CONTROL_PLANE_SYNC_TOKEN` 必须与 OAuth、workflow dispatch、策略 root 和任何券商凭证完全分离。部署 workflow 会在 `runtime-strategy-switch` environment 提供该 secret 时,把它同步为 Worker secret;每个来源仓库需要保存同一值到其专用 GitHub Environment,URL 使用只读控制台的 `/api/internal/sync-control-plane-source`
178178

179+
## 网页人工审批(P6 决策意图)
180+
181+
当且仅当候选仍是**新鲜**的 P6 `owner_decision_required`、机器建议仍为 `owner_live_decision` 时,控制台会在“全局概览”显示人工决定区。只有管理员可以选择:批准受限试运行意图、保持暂停或退役候选。
182+
183+
提交到 `POST /api/owner-decisions` 的是 `qsl_owner_decision_intent.v1`:它绑定当前候选的 P1/P2/P3/版本证据指纹,按 SHA-256 留存不可变 KV 记录,并维护当前记录索引和审计日志。证据、候选或阶段变化后,旧记录不会匹配新队列,必须重新决定。
184+
185+
固定边界:该记录始终是 `no_order=true``execution_authority_granted=false`。它不会调用 workflow、平台 API、券商、资金或订单,也不会自动启用 Live。未来只有独立的确定性执行网关验证所有当前 P4/P5/P6 条件后,才可能消费这种意图;在那之前它只是网页人工审核记录。
186+
179187
## 策略 Profile 对齐规范
180188

181189
`strategy_profile` 是切换页、runtime settings 和各平台仓库之间的统一策略 ID。

web/strategy-switch-console/app.css

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,51 @@
382382
font-size: 27px;
383383
}
384384

385+
.owner-decision {
386+
display: grid;
387+
gap: 7px;
388+
margin-top: 14px;
389+
padding-top: 13px;
390+
border-top: 1px solid var(--line);
391+
}
392+
393+
.owner-decision strong {
394+
font-size: 12px;
395+
}
396+
397+
.owner-decision small {
398+
color: var(--muted);
399+
font-size: 11px;
400+
line-height: 1.5;
401+
}
402+
403+
.owner-decision__actions {
404+
display: flex;
405+
flex-wrap: wrap;
406+
gap: 7px;
407+
}
408+
409+
.owner-decision__button {
410+
padding: 7px 9px;
411+
border: 1px solid var(--line-strong);
412+
border-radius: 7px;
413+
background: var(--surface);
414+
color: var(--ink);
415+
font: inherit;
416+
font-size: 11px;
417+
cursor: pointer;
418+
}
419+
420+
.owner-decision__button:hover:not(:disabled) {
421+
border-color: var(--accent);
422+
color: var(--accent);
423+
}
424+
425+
.owner-decision__button:disabled {
426+
cursor: wait;
427+
opacity: 0.62;
428+
}
429+
385430
.health-card__empty {
386431
grid-column: 1 / -1;
387432
padding: 30px 18px;

0 commit comments

Comments
 (0)