diff --git a/schemas/qsl-m0-research-dashboard.v1.schema.json b/schemas/qsl-m0-research-dashboard.v1.schema.json new file mode 100644 index 0000000..75b777f --- /dev/null +++ b/schemas/qsl-m0-research-dashboard.v1.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://quantstrategylab.github.io/QuantRuntimeSettings/schemas/qsl-m0-research-dashboard.v1.schema.json", + "title": "QSL M0 Research Dashboard v1", + "description": "Read-time dashboard projection of an immutable M0 research ledger. It is not the source ledger and cannot express strategy, allocation, runtime, platform, broker, or execution authority.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "source_ledger_sha256", "source_generated_at", "source_computed_at", "viewed_at", "data_status", "summary", "subjects", "policy", "errors"], + "properties": { + "schema_version": { "const": "qsl_m0_research_dashboard.v1" }, + "source_ledger_sha256": { "type": ["string", "null"], "pattern": "^[0-9a-f]{64}$" }, + "source_generated_at": { "type": ["string", "null"], "format": "date-time", "maxLength": 64 }, + "source_computed_at": { "type": ["string", "null"], "format": "date-time", "maxLength": 64 }, + "viewed_at": { "type": "string", "format": "date-time", "maxLength": 64 }, + "data_status": { "enum": ["ready", "unavailable", "stale"] }, + "summary": { "$ref": "qsl-m0-research-ledger.v1.schema.json#/properties/summary" }, + "subjects": { "$ref": "qsl-m0-research-ledger.v1.schema.json#/properties/subjects" }, + "policy": { "$ref": "qsl-m0-research-ledger.v1.schema.json#/properties/policy" }, + "errors": { "$ref": "qsl-m0-research-ledger.v1.schema.json#/properties/errors" } + }, + "allOf": [ + { + "if": { "properties": { "data_status": { "const": "unavailable" } } }, + "then": { + "properties": { + "source_ledger_sha256": { "type": "null" }, + "source_generated_at": { "type": "null" }, + "source_computed_at": { "type": "null" }, + "subjects": { "maxItems": 0 }, + "summary": { + "properties": { + "subject_count": { "const": 0 }, + "observation_count": { "const": 0 }, + "fresh_observation_count": { "const": 0 }, + "stale_observation_count": { "const": 0 }, + "unknown_observation_count": { "const": 0 }, + "horizon_conflict_count": { "const": 0 }, + "historical_stale_horizon_drift_count": { "const": 0 } + } + } + } + } + }, + { + "if": { "properties": { "data_status": { "enum": ["ready", "stale"] } } }, + "then": { + "properties": { + "source_ledger_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "source_generated_at": { "type": "string", "format": "date-time", "maxLength": 64 }, + "source_computed_at": { "type": "string", "format": "date-time", "maxLength": 64 } + } + } + } + ] +} diff --git a/tests/fixtures/qar66-m0-research-source-snapshot.json b/tests/fixtures/qar66-m0-research-source-snapshot.json new file mode 100644 index 0000000..e99f2fe --- /dev/null +++ b/tests/fixtures/qar66-m0-research-source-snapshot.json @@ -0,0 +1 @@ +{"computed_at":"2026-08-29T07:29:43Z","data_status":"ready","errors":[],"generated_at":"2026-08-29T07:29:43Z","hypotheses":[{"artifact_type":"research_hypothesis","as_of":"2026-06-20","authority":"research_only","evidence":{"evidence_ref_count":3,"risk_note_count":1,"source_entry_digest":"5018b413542d087208c8e82e976da01a9bd105c3607b8d4d398dfd4139ddb9ea"},"expires_at":"2026-09-05T07:29:43Z","generated_at":"2026-08-29T07:29:43Z","hypothesis_id":"m0r-5232ec3d400c8d9a-evt1-5018b413542d0872","no_order":true,"permitted_next_step":"research_validation_only","provenance":{"source_contract_version":"model_recommendations.v5","source_input_digest":null,"source_project":"QuantAdvisorResearch","source_report_digest":"5232ec3d400c8d9a3538d438719f44a76cfafc64d5d7f0acc5a431841bbd261c","source_schema_version":"5"},"research_context":{"primary_horizon":"medium","source_confidence":"medium","source_style":"event_driven","state":"candidate","suitable_horizons":["short","medium"],"theme_ids":[]},"schema_version":"qsl.m0_research_hypothesis.v1","subject":{"identifier":"EVT1","kind":"asset_idea"}}],"schema_version":"qsl_m0_research_source_snapshot.v1","source_id":"quant-advisor-research","source_report_digest":"5232ec3d400c8d9a3538d438719f44a76cfafc64d5d7f0acc5a431841bbd261c"} diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 1a4ec92..0472b0e 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; import worker, { __test } from "../web/strategy-switch-console/worker.js"; import { DEFAULT_ACCOUNT_OPTIONS, RUNTIME_CATALOG_PROJECTION } from "../web/strategy-switch-console/config.js"; @@ -15,6 +16,41 @@ const indexHtml = [ const bundledStrategyProfiles = JSON.parse( readFileSync(resolve(root, "web/strategy-switch-console/strategy-profiles.example.json"), "utf8"), ); +const m0ResearchDashboardSchema = JSON.parse( + readFileSync(resolve(root, "schemas/qsl-m0-research-dashboard.v1.schema.json"), "utf8"), +); +const m0ResearchPublisherEnvelopeSchema = JSON.parse( + readFileSync(resolve(root, "schemas/qsl-m0-research-publisher-envelope.v1.schema.json"), "utf8"), +); +assert.equal(m0ResearchDashboardSchema.properties.schema_version.const, "qsl_m0_research_dashboard.v1"); +assert.equal(m0ResearchDashboardSchema.additionalProperties, false); +assert.deepEqual( + m0ResearchDashboardSchema.required, + ["schema_version", "source_ledger_sha256", "source_generated_at", "source_computed_at", "viewed_at", "data_status", "summary", "subjects", "policy", "errors"], +); +assert.equal(m0ResearchPublisherEnvelopeSchema["x-qsl-canonical-utf8-max-bytes"], 256 * 1024); + +function buildQrsCanonicalM0PublisherBody(sourceSnapshotPath) { + // The fixture is a minimized, unchanged QAR #66 emitted source snapshot. + // Execute QRS #309/#310's real offline builder and return its exact sorted- + // key canonical UTF-8 request body, rather than duplicating it in JS. + const publisherCode = [ + "import hashlib,json,sys", + "sys.path.insert(0, sys.argv[2])", + "from build_m0_research_publisher_envelope import build_m0_research_publisher_envelope, canonical_envelope_body", + "raw=open(sys.argv[1], 'rb').read()", + "source=json.loads(raw)", + "envelope=build_m0_research_publisher_envelope(source_snapshot=source, source_artifact={'repository':'QuantStrategyLab/QuantAdvisorResearch','revision':'9e06f248fb60d1c995426e66468cb18454612e9b','run_id':'qar66-fixture-run','artifact_id':'QAR66:source/snapshot','sha256':hashlib.sha256(raw).hexdigest()}, producer_repository='QuantStrategyLab/QuantRuntimeSettings', producer_revision='451b11d0bf6ba2632ca2227c850e7236a40d12e5', now=source['generated_at'])", + "sys.stdout.buffer.write(canonical_envelope_body(envelope))", + ].join("; "); + const result = spawnSync( + "python3", + ["-c", publisherCode, sourceSnapshotPath, resolve(root, "python/scripts")], + { cwd: root, encoding: "buffer" }, + ); + assert.equal(result.status, 0, Buffer.from(result.stderr || "").toString("utf8")); + return Buffer.from(result.stdout); +} assert.ok(__test.currentStrategiesTimeoutMs >= 8000); const renderPlatformsBody = indexHtml.match(/function renderPlatforms\(\) \{([\s\S]*?)\n \}/)?.[1] || ""; assert.ok(!renderPlatformsBody.includes("syncStrategyForAccount(")); @@ -2657,3 +2693,250 @@ assert.equal(researchTaskReadPayload.summary.task_count, 1); assert.equal(researchTaskReadPayload.tasks[0].task.task_id, researchTask.task_id); assert.equal(researchTaskReadPayload.policy.no_order, true); assert.ok(indexHtml.includes('requestJson("/api/research-tasks")')); + +// M0 is a closed, read-only research ingress. These assertions intentionally +// exercise only its transport/KV boundary: they must never imply a selector, +// strategy, platform, dispatch, runtime, or broker action. +const m0LedgerStore = new Map(); +const m0LedgerPutOptions = []; +const m0LedgerKv = { + async get(key) { return m0LedgerStore.get(key) || null; }, + async put(key, value, options) { + m0LedgerStore.set(key, value); + m0LedgerPutOptions.push({ key, options }); + }, +}; +const m0ResearchSyncValue = ["m0", "research", "sync"].join("-"); +const m0ResearchEnv = { + ...controlEnv, + M0_RESEARCH_SYNC_TOKEN: m0ResearchSyncValue, + STRATEGY_SWITCH_CONFIG: m0LedgerKv, +}; +const m0ResearchCookie = await __test.makeSession("health-user", [], m0ResearchEnv); +const m0ResearchCookieHeaders = { Cookie: `qsl_switch_session=${m0ResearchCookie}` }; +const m0ResearchLedger = { + schema_version: "qsl_m0_research_ledger.v1", + generated_at: "2026-08-29T00:00:00Z", + computed_at: "2026-08-29T00:00:00Z", + data_status: "ready", + summary: { + subject_count: 1, + observation_count: 1, + fresh_observation_count: 1, + stale_observation_count: 0, + unknown_observation_count: 0, + horizon_conflict_count: 0, + historical_stale_horizon_drift_count: 0, + }, + subjects: [{ + subject: { kind: "theme_context", identifier: "semiconductors" }, + observations: [{ + source_ids: ["quant-advisor-research"], + source_report_digest: "a".repeat(64), + source_entry_digest: "b".repeat(64), + hypothesis_id: "m0-semiconductors-001", + as_of: "2026-08-29", + generated_at: "2026-08-29T00:00:00Z", + expires_at: "2026-09-05T00:00:00Z", + research_context: { + state: "candidate", + primary_horizon: "medium", + suitable_horizons: ["medium", "long"], + source_confidence: "medium", + source_style: "mixed_research", + theme_ids: ["semiconductors"], + }, + freshness: { status: "fresh", age_seconds: 0 }, + }], + horizon_conflict: { status: "none", primary_horizons: ["medium"] }, + historical_stale_horizon_drift: { status: "none", primary_horizons: [] }, + }], + policy: { + authority: "research_only", + no_order: true, + permitted_next_step: "research_validation_only", + notice: "Read-only M0 research ledger; it cannot select, route, or execute a strategy.", + }, + errors: [], +}; +const m0ResearchLedgerSha = await __test.calculateM0ResearchLedgerSha256(m0ResearchLedger); +const m0ResearchEnvelope = { + schema_version: "qsl_m0_research_publisher_envelope.v1", + producer: { + repository: "QuantStrategyLab/QuantRuntimeSettings", + revision: "c".repeat(40), + }, + source_artifact: { + repository: "QuantStrategyLab/QuantAdvisorResearch", + revision: "c".repeat(40), + run_id: "123456789", + artifact_id: "M0:Research/Ledger-v1", + sha256: "f".repeat(64), + }, + ledger_sha256: m0ResearchLedgerSha, + ledger: m0ResearchLedger, +}; +assert.ok( + new TextEncoder().encode(JSON.stringify(m0ResearchEnvelope)).byteLength + <= m0ResearchPublisherEnvelopeSchema["x-qsl-canonical-utf8-max-bytes"], +); +const unauthorizedM0ResearchRead = await worker.fetch( + new Request("https://switch.example/api/m0-research"), + m0ResearchEnv, +); +assert.equal(unauthorizedM0ResearchRead.status, 401); +const wrongM0ResearchToken = await worker.fetch( + new Request("https://switch.example/api/internal/sync-m0-research-ledger", { + method: "POST", + headers: { Authorization: "Bearer wrong", "Content-Type": "application/json" }, + body: JSON.stringify(m0ResearchEnvelope), + }), + m0ResearchEnv, +); +assert.equal(wrongM0ResearchToken.status, 401); +await assert.rejects( + () => __test.normalizeM0ResearchLedgerTransport({ ...m0ResearchEnvelope, unexpected: true }), + /has invalid fields/, +); +await assert.rejects( + () => __test.normalizeM0ResearchLedgerTransport({ ...m0ResearchEnvelope, ledger_sha256: "d".repeat(64) }), + /ledger_sha256 mismatch/, +); +const m0ResearchSync = await worker.fetch( + new Request("https://switch.example/api/internal/sync-m0-research-ledger", { + method: "POST", + headers: { Authorization: `Bearer ${m0ResearchSyncValue}`, "Content-Type": "application/json" }, + body: JSON.stringify(m0ResearchEnvelope), + }), + m0ResearchEnv, +); +assert.equal(m0ResearchSync.status, 200); +assert.equal((await m0ResearchSync.json()).no_order, true); +assert.ok(m0LedgerStore.has("m0_research_ledger_current")); +assert.ok(m0LedgerStore.has(`m0_research_ledger_archive:${m0ResearchLedgerSha}`)); +const storedM0ResearchCurrent = JSON.parse(m0LedgerStore.get("m0_research_ledger_current")); +assert.equal( + Date.parse(storedM0ResearchCurrent.expires_at) - Date.parse(storedM0ResearchCurrent.stored_at), + 14 * 24 * 60 * 60 * 1000, +); +const m0ResearchLedgerWrites = m0LedgerPutOptions.filter((entry) => ( + entry.key === "m0_research_ledger_current" || entry.key.startsWith("m0_research_ledger_archive:") +)); +assert.equal(m0ResearchLedgerWrites.length, 2); +assert.ok(m0ResearchLedgerWrites.every((entry) => entry.options?.expirationTtl === 14 * 24 * 60 * 60)); +const m0ResearchRead = await worker.fetch( + new Request("https://switch.example/api/m0-research", { headers: m0ResearchCookieHeaders }), + m0ResearchEnv, +); +assert.equal(m0ResearchRead.status, 200); +const m0ResearchPayload = await m0ResearchRead.json(); +assert.equal(m0ResearchPayload.schema_version, "qsl_m0_research_dashboard.v1"); +assert.equal(m0ResearchPayload.source_ledger_sha256, m0ResearchLedgerSha); +assert.equal(m0ResearchPayload.source_generated_at, m0ResearchLedger.generated_at); +assert.equal(m0ResearchPayload.source_computed_at, m0ResearchLedger.computed_at); +assert.match(m0ResearchPayload.viewed_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); +assert.equal(m0ResearchPayload.data_status, "ready"); +assert.equal(m0ResearchPayload.policy.no_order, true); +assert.equal(m0ResearchPayload.subjects[0].subject.identifier, "semiconductors"); +const qar66SourceSnapshotPath = resolve(root, "tests/fixtures/qar66-m0-research-source-snapshot.json"); +const qrsCanonicalM0PublisherBody = buildQrsCanonicalM0PublisherBody(qar66SourceSnapshotPath); +assert.ok(qrsCanonicalM0PublisherBody.byteLength <= m0ResearchPublisherEnvelopeSchema["x-qsl-canonical-utf8-max-bytes"]); +const qrsCanonicalM0Envelope = JSON.parse(qrsCanonicalM0PublisherBody.toString("utf8")); +assert.deepEqual( + Object.keys(qrsCanonicalM0Envelope.ledger.summary), + [...Object.keys(qrsCanonicalM0Envelope.ledger.summary)].sort(), +); +await __test.normalizeM0ResearchLedgerTransport(qrsCanonicalM0Envelope); +const qrsCanonicalM0Post = await worker.fetch( + new Request("https://switch.example/api/internal/sync-m0-research-ledger", { + method: "POST", + headers: { Authorization: `Bearer ${m0ResearchSyncValue}`, "Content-Type": "application/json" }, + body: qrsCanonicalM0PublisherBody, + }), + m0ResearchEnv, +); +assert.equal(qrsCanonicalM0Post.status, 200); +assert.equal((await qrsCanonicalM0Post.json()).no_order, true); +const m0ExpiredReadProjection = __test.projectM0ResearchDashboardForRead( + m0ResearchLedger, + m0ResearchLedgerSha, + new Date("2026-09-06T00:00:00Z"), +); +assert.equal(m0ExpiredReadProjection.schema_version, "qsl_m0_research_dashboard.v1"); +assert.equal(m0ExpiredReadProjection.source_ledger_sha256, m0ResearchLedgerSha); +assert.equal(m0ExpiredReadProjection.source_generated_at, m0ResearchLedger.generated_at); +assert.equal(m0ExpiredReadProjection.source_computed_at, m0ResearchLedger.computed_at); +assert.equal(m0ExpiredReadProjection.viewed_at, "2026-09-06T00:00:00Z"); +assert.equal(m0ExpiredReadProjection.data_status, "stale"); +assert.deepEqual(m0ExpiredReadProjection.summary, { + subject_count: 1, + observation_count: 1, + fresh_observation_count: 0, + stale_observation_count: 1, + unknown_observation_count: 0, + horizon_conflict_count: 0, + historical_stale_horizon_drift_count: 0, +}); +assert.equal(m0ExpiredReadProjection.subjects[0].observations[0].freshness.status, "stale"); +assert.equal(m0ResearchLedger.data_status, "ready"); +assert.equal(m0ResearchLedger.subjects[0].observations[0].freshness.status, "fresh"); +const m0ResearchReplay = await worker.fetch( + new Request("https://switch.example/api/internal/sync-m0-research-ledger", { + method: "POST", + headers: { Authorization: `Bearer ${m0ResearchSyncValue}`, "Content-Type": "application/json" }, + body: JSON.stringify(m0ResearchEnvelope), + }), + m0ResearchEnv, +); +assert.equal(m0ResearchReplay.status, 409); +const m0RollbackLedger = structuredClone(m0ResearchLedger); +m0RollbackLedger.generated_at = "2026-08-28T00:00:00Z"; +m0RollbackLedger.computed_at = "2026-08-28T00:00:00Z"; +m0RollbackLedger.subjects[0].observations[0].as_of = "2026-08-28"; +m0RollbackLedger.subjects[0].observations[0].generated_at = "2026-08-28T00:00:00Z"; +m0RollbackLedger.subjects[0].observations[0].expires_at = "2026-09-04T00:00:00Z"; +const m0RollbackSha = await __test.calculateM0ResearchLedgerSha256(m0RollbackLedger); +const m0ResearchRollback = await worker.fetch( + new Request("https://switch.example/api/internal/sync-m0-research-ledger", { + method: "POST", + headers: { Authorization: `Bearer ${m0ResearchSyncValue}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + ...m0ResearchEnvelope, + ledger_sha256: m0RollbackSha, + ledger: m0RollbackLedger, + source_artifact: { + ...m0ResearchEnvelope.source_artifact, + run_id: "123456790", + sha256: "e".repeat(64), + }, + }), + }), + m0ResearchEnv, +); +assert.equal(m0ResearchRollback.status, 409); +const m0InvalidNewPayload = await worker.fetch( + new Request("https://switch.example/api/internal/sync-m0-research-ledger", { + method: "POST", + headers: { Authorization: `Bearer ${m0ResearchSyncValue}`, "Content-Type": "application/json" }, + body: JSON.stringify({ ...m0ResearchEnvelope, ledger_sha256: "e".repeat(64) }), + }), + m0ResearchEnv, +); +assert.equal(m0InvalidNewPayload.status, 400); +const m0CurrentAfterInvalid = await worker.fetch( + new Request("https://switch.example/api/m0-research", { headers: m0ResearchCookieHeaders }), + m0ResearchEnv, +); +assert.equal((await m0CurrentAfterInvalid.json()).data_status, "ready"); +m0LedgerStore.set("m0_research_ledger_current", "{not-json"); +const m0DamagedCurrentRead = await worker.fetch( + new Request("https://switch.example/api/m0-research", { headers: m0ResearchCookieHeaders }), + m0ResearchEnv, +); +const m0DamagedCurrentPayload = await m0DamagedCurrentRead.json(); +assert.equal(m0DamagedCurrentPayload.schema_version, "qsl_m0_research_dashboard.v1"); +assert.equal(m0DamagedCurrentPayload.source_ledger_sha256, null); +assert.equal(m0DamagedCurrentPayload.source_generated_at, null); +assert.equal(m0DamagedCurrentPayload.source_computed_at, null); +assert.equal(m0DamagedCurrentPayload.data_status, "unavailable"); +assert.deepEqual(m0DamagedCurrentPayload.subjects, []); diff --git a/web/strategy-switch-console/README.zh-CN.md b/web/strategy-switch-console/README.zh-CN.md index a3238cb..7a4185d 100644 --- a/web/strategy-switch-console/README.zh-CN.md +++ b/web/strategy-switch-console/README.zh-CN.md @@ -37,6 +37,7 @@ STRATEGY_SWITCH_ADMIN_ORGS STRATEGY_HEALTH_SYNC_TOKEN CONTROL_PLANE_SYNC_TOKEN RESEARCH_TASK_SYNC_TOKEN +M0_RESEARCH_SYNC_TOKEN ``` 可选: @@ -86,6 +87,8 @@ audit_log strategy_health_snapshot control_plane_snapshot research_task_source: +m0_research_ledger_current +m0_research_ledger_archive: ``` 没有绑定 KV 时,`/admin` 只读;Worker 会回退读取 `ALLOWED_GITHUB_LOGINS`、`ALLOWED_GITHUB_ORGS`、`STRATEGY_SWITCH_ADMIN_LOGINS`、`STRATEGY_SWITCH_ADMIN_ORGS` 和 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`。 @@ -102,6 +105,48 @@ research_task_source: 它与 `/api/internal/sync-control-plane-source` 的候选快照、策略切换 token、策略根和券商凭据完全分离。该索引只显示任务,不会运行任务、调优参数、改代码、创建/合并 PR、部署、进入 paper/shadow/live 或触碰账户与订单。 +## M0 顾投研究台账入口 + +`POST /api/internal/sync-m0-research-ledger` 与 `GET /api/m0-research` 是顾投研究台账的 +只读边界;读取端仍要求已登录的 allowlist 用户。写入端只接受专用 +`M0_RESEARCH_SYNC_TOKEN`,不能复用 OAuth、策略切换、控制面、M1 Shadow、研究任务、 +平台或任何券商凭据。 + +请求体必须是字段闭合的 `qsl_m0_research_publisher_envelope.v1`:root 固定为 +`schema_version`、`producer`、`source_artifact`、`ledger_sha256` 和嵌入的 +`qsl_m0_research_ledger.v1`。`producer` 固定为仓库/revision;`source_artifact` 固定为 +仓库、40 位 revision、run ID、artifact ID 与 artifact SHA-256。Worker 重新以 canonical +JSON 计算并验证 `ledger_sha256`;`source_artifact.sha256` 是已认证的原始顾投 artifact +声明,故意不与派生 ledger 的 SHA-256 混为同一个值。来源仓库目前固定为 +`QuantStrategyLab/QuantAdvisorResearch`,生产者仓库固定为 +`QuantStrategyLab/QuantRuntimeSettings`。任何额外字段、错误摘要、未来时间、损坏台账或 +越界语义都会在写入前被拒绝。发布构建器自 #310 起已经在生成端约束紧凑 UTF-8 envelope +不超过 262,144 bytes;Worker 保留同一请求体上限作为接收端边界,不另行转换或放宽该契约。 + +Worker 固定使用 `m0_research_ledger_current` 和由已校验 digest 派生的 +`m0_research_ledger_archive:`;调用方不能传入 KV key。current 记录检测到 +重复 artifact/ledger、相同 source run ID 或 ledger 时间回退会返回 `409`。这是基于 KV +当前记录的 **best-effort** 重放/回退保护:Cloudflare KV 不是线性一致的比较并交换存储, +并发写入仍不能被表述为强原子顺序保证。本接口不为此新增 Durable Object 或其他绑定;它的 +职责仍限于 no-order 研究资料接收。current 与 archive 均使用 Cloudflare KV 的 14 天物理 +TTL;KV 缺失、过期、损坏或校验失败时,读取接口只返回空的 `unavailable` no-order 结构, +绝不猜测旧研究结论。 + +`GET /api/m0-research` 返回字段闭合的 **`qsl_m0_research_dashboard.v1`**,而不是 +`qsl_m0_research_ledger.v1`。dashboard 固定携带原始 `source_ledger_sha256`、source 的 +`generated_at` / `computed_at` 和 `viewed_at`,再给出派生的 `data_status`、summary、subjects、 +policy 与 errors。无可用记录时也返回同一 dashboard schema 的 `unavailable` 空结构。 + +dashboard 不会直接复用已存储的 `freshness`:它会在每次读取时按照每条 observation 的 +`expires_at` 派生新的 `fresh/stale` 状态,并重新计算 subject 冲突、summary 和 `data_status`。 +这份只读投影不会写回 KV,也不会修改 hash-bound 原始 ledger;也**不声称**仍可通过原 ledger +validator。来源已标为 stale 的观测不会因读取而被提升为 fresh。 + +该入口**不**调用 selector、策略目录、平台配置、dispatch、runtime、插件或券商,也不创建 +研究任务。输出永远固定为 `authority=research_only`、`no_order=true` 和 +`permitted_next_step=research_validation_only`;将 M0 线索转为 P1--P3 研究任务仍需独立的 +证据绑定与准入流程。 + ## 文件结构 ```text diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 10c541e..e9c2a86 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -82,6 +82,34 @@ const ADAPTIVE_SELECTION_MAX_SOURCES = 100; const ADAPTIVE_SELECTION_MAX_BODY_BYTES = 256 * 1024; const ADAPTIVE_SELECTION_DEFAULT_STALE_TTL_SECONDS = 36 * 60 * 60; const ADAPTIVE_SELECTION_AUTHORITY = "shadow_only"; +// M0 research arrives through a separate, signed-at-transport-boundary +// snapshot. It is deliberately neither an M1 selection input nor a research +// task: this Worker only retains a closed, no-order ledger for authenticated +// readers. No strategy/platform/runtime/broker helper may consume it here. +const M0_RESEARCH_TRANSPORT_SCHEMA_VERSION = "qsl_m0_research_publisher_envelope.v1"; +const M0_RESEARCH_LEDGER_SCHEMA_VERSION = "qsl_m0_research_ledger.v1"; +const M0_RESEARCH_DASHBOARD_SCHEMA_VERSION = "qsl_m0_research_dashboard.v1"; +const M0_RESEARCH_STORAGE_SCHEMA_VERSION = "qsl_m0_research_ledger_storage.v1"; +const M0_RESEARCH_CURRENT_KEY = "m0_research_ledger_current"; +const M0_RESEARCH_ARCHIVE_PREFIX = "m0_research_ledger_archive:"; +const M0_RESEARCH_MAX_BODY_BYTES = 256 * 1024; +const M0_RESEARCH_RETENTION_SECONDS = 14 * 24 * 60 * 60; +const M0_RESEARCH_ALLOWED_SOURCE_REPOSITORY = "QuantStrategyLab/QuantAdvisorResearch"; +const M0_RESEARCH_ALLOWED_PRODUCER_REPOSITORY = "QuantStrategyLab/QuantRuntimeSettings"; +const M0_RESEARCH_SUBJECT_KINDS = ["asset_idea", "theme_context", "strategy_hypothesis", "risk_context"]; +const M0_RESEARCH_HORIZONS = ["short", "medium", "long", "not_applicable"]; +const M0_RESEARCH_STATES = ["candidate", "source_verification_required", "deferred", "context_only"]; +const M0_RESEARCH_CONFIDENCE = ["high", "medium", "low", "mixed", "no_event", "unknown"]; +const M0_RESEARCH_STYLES = ["event_driven", "long_horizon_growth", "value_quality", "macro_context", "mixed_research"]; +const M0_RESEARCH_SUMMARY_FIELDS = [ + "subject_count", + "observation_count", + "fresh_observation_count", + "stale_observation_count", + "unknown_observation_count", + "horizon_conflict_count", + "historical_stale_horizon_drift_count", +]; // A console decision is intentionally an auditable owner intent, not an // execution permit. It remains separate from workflow dispatch credentials, // broker credentials, and any future deterministic execution gateway. @@ -270,6 +298,12 @@ export default { if (url.pathname === "/api/adaptive-selection" && request.method === "GET") { return await adaptiveSelectionResponse(request, env); } + if (url.pathname === "/api/internal/sync-m0-research-ledger" && request.method === "POST") { + return await syncM0ResearchLedgerResponse(request, env); + } + if (url.pathname === "/api/m0-research" && request.method === "GET") { + return await m0ResearchLedgerResponse(request, env); + } if (url.pathname === "/api/owner-decisions" && request.method === "GET") { return await ownerDecisionQueueResponse(request, env); } @@ -2200,6 +2234,142 @@ async function syncAdaptiveSelectionSourceResponse(request, env) { }); } +async function syncM0ResearchLedgerResponse(request, env) { + requireDedicatedM0ResearchSyncToken(request, env); + if (!hasConfigStore(env)) { + return json({ ok: false, error: "M0 research ledger KV is not configured" }, 503); + } + + let raw; + try { + raw = await readBoundedJson(request, M0_RESEARCH_MAX_BODY_BYTES); + } catch (error) { + return json({ ok: false, error: error.message || "invalid M0 research ledger payload" }, error.status || 400); + } + + let envelope; + try { + envelope = await normalizeM0ResearchLedgerTransport(raw, "M0 research ledger transport"); + } catch (error) { + // Validation deliberately completes before any KV write. A malformed, + // over-scoped, or digest-mismatched payload can therefore never replace + // the last known-good current ledger. + return json({ ok: false, error: error.message || "invalid M0 research ledger payload" }, 400); + } + + const current = await readCurrentM0ResearchLedgerRecord(env); + if (current) { + const replayError = m0ResearchLedgerReplayError(current.envelope, envelope); + if (replayError) return json({ ok: false, error: replayError }, 409); + } + + // Capture once so the persisted interval is exactly the requested physical + // retention even when the request crosses a wall-clock second boundary. + const storedNow = new Date(); + const storedAt = utcTimestampSeconds(storedNow); + const expiresAt = utcTimestampSeconds(new Date(storedNow.getTime() + M0_RESEARCH_RETENTION_SECONDS * 1000)); + const record = { + schema_version: M0_RESEARCH_STORAGE_SCHEMA_VERSION, + stored_at: storedAt, + expires_at: expiresAt, + envelope, + }; + try { + // Archive first: an archive failure must not advance the current pointer. + // The archive identity is derived exclusively from the verified digest; + // callers never choose a KV key. + await writeM0ResearchLedgerRecord(env, m0ResearchLedgerArchiveKey(envelope.ledger_sha256), record); + await writeM0ResearchLedgerRecord(env, M0_RESEARCH_CURRENT_KEY, record); + } catch { + return json({ ok: false, error: "M0 research ledger persistence failed" }, 503); + } + + try { + await appendAuditLog(env, { + ts: storedAt, + login: "m0-research-ledger-sync", + action: "sync_m0_research_ledger", + source_repository: envelope.source_artifact.repository, + source_revision: envelope.source_artifact.revision, + source_run_id: envelope.source_artifact.run_id, + artifact_id: envelope.source_artifact.artifact_id, + ledger_sha256: envelope.ledger_sha256, + no_order: true, + }); + } catch { + // Retention of an optional convenience log must not change a valid, + // already persisted no-order research ledger. + } + return json({ + ok: true, + schema_version: envelope.schema_version, + source_repository: envelope.source_artifact.repository, + source_revision: envelope.source_artifact.revision, + source_run_id: envelope.source_artifact.run_id, + artifact_id: envelope.source_artifact.artifact_id, + ledger_sha256: envelope.ledger_sha256, + no_order: true, + expires_at: expiresAt, + }); +} + +async function m0ResearchLedgerResponse(request, env) { + const session = await readSession(request, env); + if (!session?.allowed) return json({ ok: false, error: "login required" }, 401); + if (!hasConfigStore(env)) return json(emptyM0ResearchDashboardPayload("m0_research_ledger_unavailable")); + const record = await readCurrentM0ResearchLedgerRecord(env); + return json(record + ? projectM0ResearchDashboardForRead(record.envelope.ledger, record.envelope.ledger_sha256) + : emptyM0ResearchDashboardPayload("m0_research_ledger_unavailable")); +} + +function m0ResearchLedgerReplayError(current, incoming) { + if (!current || !incoming) return null; + if (incoming.ledger_sha256 === current.ledger_sha256 + || incoming.source_artifact.sha256 === current.source_artifact.sha256) { + return "M0 research ledger current replay rejected"; + } + // Run IDs are closed identifiers rather than an assumed numeric sequence. + // An equal source/run identity is a replay; ledger time is the portable + // ordering guard for a different run identifier. + if (incoming.source_artifact.repository === current.source_artifact.repository + && incoming.source_artifact.run_id === current.source_artifact.run_id) { + return "M0 research ledger current replay rejected"; + } + const incomingTime = Date.parse(incoming.ledger.computed_at); + const currentTime = Date.parse(current.ledger.computed_at); + if (Number.isFinite(incomingTime) && Number.isFinite(currentTime) && incomingTime <= currentTime) { + return "M0 research ledger time rollback rejected"; + } + return null; +} + +function m0ResearchLedgerArchiveKey(ledgerSha256) { + return `${M0_RESEARCH_ARCHIVE_PREFIX}${ledgerSha256}`; +} + +async function writeM0ResearchLedgerRecord(env, key, record) { + const store = configStore(env); + if (!store) throw new Error("M0 research ledger KV is not configured"); + await store.put(key, JSON.stringify(record), { expirationTtl: M0_RESEARCH_RETENTION_SECONDS }); +} + +async function readCurrentM0ResearchLedgerRecord(env) { + if (!hasConfigStore(env)) return null; + let stored; + try { + stored = await readConfigJson(env, M0_RESEARCH_CURRENT_KEY); + } catch { + return null; + } + if (!stored) return null; + try { + return await normalizeM0ResearchLedgerStorageRecord(stored, "M0 research ledger current record"); + } catch { + return null; + } +} + async function adaptiveSelectionResponse(request, env) { const session = await readSession(request, env); if (!session?.allowed) return json({ ok: false, error: "login required" }, 401); @@ -2859,6 +3029,492 @@ function emptyControlPlaneSourceSnapshot(errorCode) { }; } +function requireDedicatedM0ResearchSyncToken(request, env) { + const expected = String(env.M0_RESEARCH_SYNC_TOKEN || ""); + if (!expected) throw new HttpError("M0 research sync token is not configured", 500); + const header = request.headers.get("Authorization") || ""; + const token = header.match(/^Bearer\s+(.+)$/i)?.[1] || ""; + if (token !== expected) throw new HttpError("M0 research sync token is invalid", 401); +} + +async function normalizeM0ResearchLedgerTransport(payload, fieldName = "M0 research ledger transport") { + const source = assertExactFields(payload, [ + "schema_version", "producer", "source_artifact", "ledger_sha256", "ledger", + ], fieldName); + if (source.schema_version !== M0_RESEARCH_TRANSPORT_SCHEMA_VERSION) { + throw new Error(`${fieldName}.schema_version is unsupported`); + } + const normalized = { + schema_version: M0_RESEARCH_TRANSPORT_SCHEMA_VERSION, + producer: normalizeM0ResearchProducer(source.producer, `${fieldName}.producer`), + source_artifact: normalizeM0ResearchSourceArtifact(source.source_artifact, `${fieldName}.source_artifact`), + ledger_sha256: normalizeResearchTaskDigest(source.ledger_sha256, `${fieldName}.ledger_sha256`), + ledger: normalizeM0ResearchLedger(source.ledger, `${fieldName}.ledger`), + }; + // The ledger digest binds the closed embedded ledger. source_artifact.sha256 + // intentionally remains a distinct immutable declaration about the QAR + // source artifact; it is not a hash of this derived ledger. + const recomputed = await calculateM0ResearchLedgerSha256(normalized.ledger); + if (normalized.ledger_sha256 !== recomputed) { + throw new Error(`${fieldName}.ledger_sha256 mismatch`); + } + const computedAt = Date.parse(normalized.ledger.computed_at); + if (!Number.isFinite(computedAt) || computedAt > Date.now() + 5 * 60 * 1000) { + throw new Error(`${fieldName}.ledger.computed_at is in the future`); + } + if (computedAt < Date.now() - M0_RESEARCH_RETENTION_SECONDS * 1000) { + throw new Error(`${fieldName}.ledger is expired`); + } + return normalized; +} + +async function normalizeM0ResearchLedgerStorageRecord(payload, fieldName) { + const record = assertExactFields(payload, ["schema_version", "stored_at", "expires_at", "envelope"], fieldName); + if (record.schema_version !== M0_RESEARCH_STORAGE_SCHEMA_VERSION) { + throw new Error(`${fieldName}.schema_version is unsupported`); + } + const storedAt = normalizeM0ResearchTimestamp(record.stored_at, `${fieldName}.stored_at`); + const expiresAt = normalizeM0ResearchTimestamp(record.expires_at, `${fieldName}.expires_at`); + const storedMillis = Date.parse(storedAt); + const expiresMillis = Date.parse(expiresAt); + if (expiresMillis - storedMillis !== M0_RESEARCH_RETENTION_SECONDS * 1000 || expiresMillis <= Date.now()) { + throw new Error(`${fieldName} is expired`); + } + return { + schema_version: M0_RESEARCH_STORAGE_SCHEMA_VERSION, + stored_at: storedAt, + expires_at: expiresAt, + envelope: await normalizeM0ResearchLedgerTransport(record.envelope, `${fieldName}.envelope`), + }; +} + +function normalizeM0ResearchLedger(payload, fieldName) { + const ledger = assertExactFields(payload, [ + "schema_version", "generated_at", "computed_at", "data_status", "summary", "subjects", "policy", "errors", + ], fieldName); + if (ledger.schema_version !== M0_RESEARCH_LEDGER_SCHEMA_VERSION) { + throw new Error(`${fieldName}.schema_version is unsupported`); + } + const generatedAt = normalizeM0ResearchTimestamp(ledger.generated_at, `${fieldName}.generated_at`); + const computedAt = normalizeM0ResearchTimestamp(ledger.computed_at, `${fieldName}.computed_at`); + if (generatedAt !== computedAt || /\./.test(generatedAt)) { + throw new Error(`${fieldName} timestamps must be the same canonical second`); + } + const dataStatus = cleanChoice(ledger.data_status, ["ready", "unavailable", "stale"], `${fieldName}.data_status`); + if (!Array.isArray(ledger.subjects) || ledger.subjects.length > 50000) { + throw new Error(`${fieldName}.subjects must be a bounded array`); + } + const subjects = ledger.subjects.map((subject, index) => normalizeM0ResearchSubject( + subject, `${fieldName}.subjects[${index}]`, computedAt, + )); + assertM0ResearchSortedUnique(subjects, (subject) => `${subject.subject.kind}\u0000${subject.subject.identifier}`, `${fieldName}.subjects`); + const summary = normalizeM0ResearchLedgerSummary(ledger.summary, `${fieldName}.summary`); + const calculatedSummary = summarizeM0ResearchSubjects(subjects); + if (!m0ResearchSummariesEqual(summary, calculatedSummary)) { + throw new Error(`${fieldName}.summary does not match subjects`); + } + const expectedStatus = summary.fresh_observation_count > 0 + ? "ready" + : (summary.observation_count > 0 ? "stale" : "unavailable"); + if (dataStatus !== expectedStatus) throw new Error(`${fieldName}.data_status does not match subjects`); + if (dataStatus === "unavailable" && subjects.length) throw new Error(`${fieldName}.subjects must be empty when unavailable`); + return { + schema_version: M0_RESEARCH_LEDGER_SCHEMA_VERSION, + generated_at: generatedAt, + computed_at: computedAt, + data_status: dataStatus, + summary, + subjects, + policy: normalizeM0ResearchLedgerPolicy(ledger.policy, `${fieldName}.policy`), + errors: normalizeM0ResearchErrorCodes(ledger.errors, `${fieldName}.errors`), + }; +} + +function normalizeM0ResearchLedgerSummary(value, fieldName) { + const summary = assertExactFields(value, M0_RESEARCH_SUMMARY_FIELDS, fieldName); + const result = {}; + for (const key of M0_RESEARCH_SUMMARY_FIELDS) { + result[key] = normalizeM0ResearchCount(summary[key], `${fieldName}.${key}`); + } + return result; +} + +function m0ResearchSummariesEqual(left, right) { + return M0_RESEARCH_SUMMARY_FIELDS.every((field) => left[field] === right[field]); +} + +function normalizeM0ResearchLedgerPolicy(value, fieldName) { + const policy = assertExactFields(value, ["authority", "no_order", "permitted_next_step", "notice"], fieldName); + if (policy.authority !== "research_only" || policy.no_order !== true || policy.permitted_next_step !== "research_validation_only") { + throw new Error(`${fieldName} must remain research-only and no-order`); + } + return { + authority: "research_only", + no_order: true, + permitted_next_step: "research_validation_only", + notice: normalizeM0ResearchText(policy.notice, `${fieldName}.notice`, 240), + }; +} + +function normalizeM0ResearchSubject(value, fieldName, ledgerComputedAt) { + const item = assertExactFields(value, ["subject", "observations", "horizon_conflict", "historical_stale_horizon_drift"], fieldName); + const subject = assertExactFields(item.subject, ["kind", "identifier"], `${fieldName}.subject`); + const normalizedSubject = { + kind: cleanChoice(subject.kind, M0_RESEARCH_SUBJECT_KINDS, `${fieldName}.subject.kind`), + identifier: normalizeM0ResearchIdentifier(subject.identifier, `${fieldName}.subject.identifier`), + }; + if (!Array.isArray(item.observations) || !item.observations.length || item.observations.length > 100) { + throw new Error(`${fieldName}.observations must be a non-empty bounded array`); + } + const observations = item.observations.map((observation, index) => normalizeM0ResearchObservation( + observation, `${fieldName}.observations[${index}]`, ledgerComputedAt, + )); + assertM0ResearchSortedUnique(observations, (observation) => ( + `${observation.source_report_digest}\u0000${observation.source_entry_digest}` + ), `${fieldName}.observations`); + const expectedHorizonView = calculateM0ResearchHorizonViews(observations); + const horizonConflict = normalizeM0ResearchHorizonView( + item.horizon_conflict, `${fieldName}.horizon_conflict`, ["none", "conflict"], expectedHorizonView.horizon_conflict, + ); + const historicalStaleHorizonDrift = normalizeM0ResearchHorizonView( + item.historical_stale_horizon_drift, `${fieldName}.historical_stale_horizon_drift`, + ["none", "drift", "unavailable"], expectedHorizonView.historical_stale_horizon_drift, + ); + return { + subject: normalizedSubject, + observations, + horizon_conflict: horizonConflict, + historical_stale_horizon_drift: historicalStaleHorizonDrift, + }; +} + +function normalizeM0ResearchObservation(value, fieldName, ledgerComputedAt) { + const item = assertExactFields(value, [ + "source_ids", "source_report_digest", "source_entry_digest", "hypothesis_id", "as_of", "generated_at", "expires_at", + "research_context", "freshness", + ], fieldName); + if (!Array.isArray(item.source_ids) || !item.source_ids.length || item.source_ids.length > 100) { + throw new Error(`${fieldName}.source_ids must be a non-empty bounded array`); + } + const sourceIds = item.source_ids.map((sourceId, index) => normalizeM0ResearchIdentifier(sourceId, `${fieldName}.source_ids[${index}]`)); + assertM0ResearchSortedUnique(sourceIds, (sourceId) => sourceId, `${fieldName}.source_ids`); + const generatedAt = normalizeM0ResearchTimestamp(item.generated_at, `${fieldName}.generated_at`); + const expiresAt = normalizeM0ResearchTimestamp(item.expires_at, `${fieldName}.expires_at`); + const generatedMillis = Date.parse(generatedAt); + const expiresMillis = Date.parse(expiresAt); + if (expiresMillis - generatedMillis !== 7 * 24 * 60 * 60 * 1000) { + throw new Error(`${fieldName} must have the fixed seven-day M0 expiry`); + } + const asOf = normalizeM0ResearchDate(item.as_of, `${fieldName}.as_of`); + if (asOf > generatedAt.slice(0, 10)) throw new Error(`${fieldName}.as_of cannot be after generated_at`); + const context = normalizeM0ResearchContext(item.research_context, `${fieldName}.research_context`); + const freshness = normalizeM0ResearchFreshness( + item.freshness, `${fieldName}.freshness`, generatedMillis, expiresMillis, Date.parse(ledgerComputedAt), + ); + return { + source_ids: sourceIds, + source_report_digest: normalizeResearchTaskDigest(item.source_report_digest, `${fieldName}.source_report_digest`), + source_entry_digest: normalizeResearchTaskDigest(item.source_entry_digest, `${fieldName}.source_entry_digest`), + hypothesis_id: normalizeM0ResearchIdentifier(item.hypothesis_id, `${fieldName}.hypothesis_id`), + as_of: asOf, + generated_at: generatedAt, + expires_at: expiresAt, + research_context: context, + freshness, + }; +} + +function normalizeM0ResearchContext(value, fieldName) { + const context = assertExactFields(value, [ + "state", "primary_horizon", "suitable_horizons", "source_confidence", "source_style", "theme_ids", + ], fieldName); + const primaryHorizon = cleanChoice(context.primary_horizon, M0_RESEARCH_HORIZONS, `${fieldName}.primary_horizon`); + if (!Array.isArray(context.suitable_horizons) || !context.suitable_horizons.length || context.suitable_horizons.length > 4) { + throw new Error(`${fieldName}.suitable_horizons is invalid`); + } + const suitableHorizons = context.suitable_horizons.map((horizon, index) => cleanChoice( + horizon, M0_RESEARCH_HORIZONS, `${fieldName}.suitable_horizons[${index}]`, + )); + if (!suitableHorizons.includes(primaryHorizon)) throw new Error(`${fieldName}.primary_horizon must be suitable`); + assertM0ResearchUnique(suitableHorizons, `${fieldName}.suitable_horizons`); + if (!Array.isArray(context.theme_ids) || context.theme_ids.length > 24) throw new Error(`${fieldName}.theme_ids is invalid`); + const themeIds = context.theme_ids.map((themeId, index) => normalizeM0ResearchIdentifier(themeId, `${fieldName}.theme_ids[${index}]`)); + assertM0ResearchUnique(themeIds, `${fieldName}.theme_ids`); + return { + state: cleanChoice(context.state, M0_RESEARCH_STATES, `${fieldName}.state`), + primary_horizon: primaryHorizon, + suitable_horizons: suitableHorizons, + source_confidence: cleanChoice(context.source_confidence, M0_RESEARCH_CONFIDENCE, `${fieldName}.source_confidence`), + source_style: cleanChoice(context.source_style, M0_RESEARCH_STYLES, `${fieldName}.source_style`), + theme_ids: themeIds, + }; +} + +function normalizeM0ResearchFreshness(value, fieldName, generatedMillis, expiresMillis, ledgerComputedMillis) { + const freshness = assertExactFields(value, ["status", "age_seconds"], fieldName); + const status = cleanChoice(freshness.status, ["fresh", "stale", "unknown"], `${fieldName}.status`); + const expectedAge = generatedMillis > ledgerComputedMillis + ? null + : Math.max(0, Math.floor((ledgerComputedMillis - generatedMillis) / 1000)); + const age = freshness.age_seconds === null ? null : normalizeM0ResearchCount(freshness.age_seconds, `${fieldName}.age_seconds`, 315360000); + if (status === "unknown") { + if (age !== null || generatedMillis <= ledgerComputedMillis) throw new Error(`${fieldName} unknown state is invalid`); + return { status, age_seconds: null }; + } + if (age !== expectedAge || generatedMillis > ledgerComputedMillis) throw new Error(`${fieldName}.age_seconds is invalid`); + if (status === "fresh" && ledgerComputedMillis >= expiresMillis) throw new Error(`${fieldName} cannot be fresh after expiry`); + return { status, age_seconds: age }; +} + +function calculateM0ResearchHorizonViews(observations) { + const freshHorizons = [...new Set(observations + .filter((observation) => observation.freshness.status === "fresh") + .map((observation) => observation.research_context.primary_horizon))].sort(); + const staleHorizons = [...new Set(observations + .filter((observation) => observation.freshness.status === "stale") + .map((observation) => observation.research_context.primary_horizon))].sort(); + return { + horizon_conflict: { status: freshHorizons.length > 1 ? "conflict" : "none", primary_horizons: freshHorizons }, + historical_stale_horizon_drift: { + status: freshHorizons.length + ? (staleHorizons.length && JSON.stringify(staleHorizons) !== JSON.stringify(freshHorizons) ? "drift" : "none") + : (staleHorizons.length ? "unavailable" : "none"), + primary_horizons: staleHorizons, + }, + }; +} + +function projectM0ResearchDashboardForRead(ledger, sourceLedgerSha256, now = new Date()) { + // The stored envelope remains the immutable, digest-bound publication + // record. This is deliberately a different dashboard schema: changing + // freshness at read time must never pretend to be a re-validatable ledger. + const nowMillis = now instanceof Date ? now.getTime() : Date.parse(now); + if (!Number.isFinite(nowMillis)) return emptyM0ResearchDashboardPayload("m0_research_ledger_unavailable"); + const subjects = ledger.subjects.map((entry) => { + const observations = entry.observations.map((observation) => ({ + ...observation, + freshness: projectM0ResearchObservationFreshness(observation, nowMillis), + })); + const horizonViews = calculateM0ResearchHorizonViews(observations); + return { + subject: { ...entry.subject }, + observations, + horizon_conflict: horizonViews.horizon_conflict, + historical_stale_horizon_drift: horizonViews.historical_stale_horizon_drift, + }; + }); + const summary = summarizeM0ResearchSubjects(subjects); + const dataStatus = summary.fresh_observation_count > 0 + ? "ready" + : (summary.observation_count > 0 ? "stale" : "unavailable"); + return { + schema_version: M0_RESEARCH_DASHBOARD_SCHEMA_VERSION, + source_ledger_sha256: sourceLedgerSha256, + source_generated_at: ledger.generated_at, + source_computed_at: ledger.computed_at, + viewed_at: utcTimestampSeconds(new Date(nowMillis)), + data_status: dataStatus, + summary, + subjects, + policy: { ...ledger.policy }, + errors: [...ledger.errors], + }; +} + +function projectM0ResearchObservationFreshness(observation, nowMillis) { + const generatedMillis = Date.parse(observation.generated_at); + const expiresMillis = Date.parse(observation.expires_at); + if (!Number.isFinite(generatedMillis) || !Number.isFinite(expiresMillis) || nowMillis < generatedMillis) { + return { status: "unknown", age_seconds: null }; + } + const ageSeconds = Math.max(0, Math.floor((nowMillis - generatedMillis) / 1000)); + // A stale source is never promoted by this projection. A former fresh + // observation is demoted as soon as its own expiry is reached. + const status = observation.freshness.status === "fresh" && nowMillis < expiresMillis + ? "fresh" + : "stale"; + return { status, age_seconds: ageSeconds }; +} + +function normalizeM0ResearchHorizonView(value, fieldName, allowedStatuses, expected) { + const view = assertExactFields(value, ["status", "primary_horizons"], fieldName); + const status = cleanChoice(view.status, allowedStatuses, `${fieldName}.status`); + if (!Array.isArray(view.primary_horizons) || view.primary_horizons.length > 4) { + throw new Error(`${fieldName}.primary_horizons is invalid`); + } + const horizons = view.primary_horizons.map((horizon, index) => cleanChoice( + horizon, M0_RESEARCH_HORIZONS, `${fieldName}.primary_horizons[${index}]`, + )); + assertM0ResearchSortedUnique(horizons, (horizon) => horizon, `${fieldName}.primary_horizons`); + if (status !== expected.status || JSON.stringify(horizons) !== JSON.stringify(expected.primary_horizons)) { + throw new Error(`${fieldName} does not match observations`); + } + return { status, primary_horizons: horizons }; +} + +function summarizeM0ResearchSubjects(subjects) { + const observations = subjects.flatMap((subject) => subject.observations); + return { + subject_count: subjects.length, + observation_count: observations.length, + fresh_observation_count: observations.filter((observation) => observation.freshness.status === "fresh").length, + stale_observation_count: observations.filter((observation) => observation.freshness.status === "stale").length, + unknown_observation_count: observations.filter((observation) => observation.freshness.status === "unknown").length, + horizon_conflict_count: subjects.filter((subject) => subject.horizon_conflict.status === "conflict").length, + historical_stale_horizon_drift_count: subjects.filter((subject) => subject.historical_stale_horizon_drift.status === "drift").length, + }; +} + +function normalizeM0ResearchErrorCodes(value, fieldName) { + if (!Array.isArray(value) || value.length > 20) throw new Error(`${fieldName} is invalid`); + const errors = value.map((error, index) => { + if (typeof error !== "string" || !/^[a-z][a-z0-9_.-]{0,63}$/.test(error)) { + throw new Error(`${fieldName}[${index}] is invalid`); + } + return error; + }); + assertM0ResearchSortedUnique(errors, (error) => error, fieldName); + return errors; +} + +function normalizeM0ResearchRepository(value, fieldName, allowedRepository) { + const repository = normalizeResearchTaskRepository(value, fieldName); + if (repository !== allowedRepository) throw new Error(`${fieldName} is not an approved M0 repository`); + return repository; +} + +function normalizeM0ResearchRevision(value, fieldName) { + return normalizeResearchTaskRevision(value, fieldName); +} + +function normalizeM0ResearchProducer(value, fieldName) { + const producer = assertExactFields(value, ["repository", "revision"], fieldName); + return { + repository: normalizeM0ResearchRepository( + producer.repository, `${fieldName}.repository`, M0_RESEARCH_ALLOWED_PRODUCER_REPOSITORY, + ), + revision: normalizeM0ResearchRevision(producer.revision, `${fieldName}.revision`), + }; +} + +function normalizeM0ResearchSourceArtifact(value, fieldName) { + const artifact = assertExactFields(value, ["repository", "revision", "run_id", "artifact_id", "sha256"], fieldName); + return { + repository: normalizeM0ResearchRepository( + artifact.repository, `${fieldName}.repository`, M0_RESEARCH_ALLOWED_SOURCE_REPOSITORY, + ), + revision: normalizeM0ResearchRevision(artifact.revision, `${fieldName}.revision`), + run_id: normalizeM0ResearchIdentifier(artifact.run_id, `${fieldName}.run_id`), + artifact_id: normalizeM0ResearchArtifactId(artifact.artifact_id, `${fieldName}.artifact_id`), + sha256: normalizeResearchTaskDigest(artifact.sha256, `${fieldName}.sha256`), + }; +} + +function normalizeM0ResearchArtifactId(value, fieldName) { + // Keep this exactly aligned with #309's generic identifier schema. Artifact + // IDs are provenance labels, not strategy slugs, so uppercase and `:/` are + // valid when they satisfy the closed source-artifact contract. + return normalizeM0ResearchIdentifier(value, fieldName); +} + +function normalizeM0ResearchIdentifier(value, fieldName) { + if (typeof value !== "string" || !/^[A-Za-z0-9._:/-]{1,128}$/.test(value)) { + throw new Error(`${fieldName} is invalid`); + } + return value; +} + +function normalizeM0ResearchTimestamp(value, fieldName) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) || Number.isNaN(Date.parse(value))) { + throw new Error(`${fieldName} must be a UTC timestamp`); + } + if (new Date(value).toISOString().slice(0, 10) !== value.slice(0, 10)) { + throw new Error(`${fieldName} must be a real UTC timestamp`); + } + return value; +} + +function normalizeM0ResearchDate(value, fieldName) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) { + throw new Error(`${fieldName} must be an ISO date`); + } + if (new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value) { + throw new Error(`${fieldName} must be a real ISO date`); + } + return value; +} + +function normalizeM0ResearchCount(value, fieldName, maximum = 50000) { + if (!Number.isInteger(value) || value < 0 || value > maximum) throw new Error(`${fieldName} is invalid`); + return value; +} + +function normalizeM0ResearchText(value, fieldName, maximum) { + if (typeof value !== "string" || !value || value.length > maximum || /[<>\\\u0000-\u001f]/.test(value)) { + throw new Error(`${fieldName} is invalid`); + } + return value; +} + +function assertM0ResearchUnique(values, fieldName) { + if (new Set(values).size !== values.length) throw new Error(`${fieldName} contains duplicates`); +} + +function assertM0ResearchSortedUnique(values, key, fieldName) { + let previous = null; + for (const value of values) { + const current = key(value); + if (previous !== null && current <= previous) throw new Error(`${fieldName} must be sorted and unique`); + previous = current; + } +} + +function canonicalM0ResearchLedgerJson(value) { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("M0 research ledger must use finite JSON values"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalM0ResearchLedgerJson).join(",")}]`; + if (!value || typeof value !== "object") throw new Error("M0 research ledger must use JSON values"); + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalM0ResearchLedgerJson(value[key])}`).join(",")}}`; +} + +async function calculateM0ResearchLedgerSha256(ledger) { + const raw = new TextEncoder().encode(canonicalM0ResearchLedgerJson(ledger)); + const digest = await crypto.subtle.digest("SHA-256", raw); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function emptyM0ResearchDashboardPayload(errorCode, now = new Date()) { + return { + schema_version: M0_RESEARCH_DASHBOARD_SCHEMA_VERSION, + source_ledger_sha256: null, + source_generated_at: null, + source_computed_at: null, + viewed_at: utcTimestampSeconds(now), + data_status: "unavailable", + summary: { + subject_count: 0, + observation_count: 0, + fresh_observation_count: 0, + stale_observation_count: 0, + unknown_observation_count: 0, + horizon_conflict_count: 0, + historical_stale_horizon_drift_count: 0, + }, + subjects: [], + policy: { + authority: "research_only", + no_order: true, + permitted_next_step: "research_validation_only", + notice: "M0 研究台账不可用;不会推断策略、平台、运行状态或订单。", + }, + errors: [errorCode], + }; +} + async function normalizeAdaptiveSelectionSourceSnapshot(payload, fieldName = "adaptive selection source snapshot") { const source = assertExactFields(payload, [ "schema_version", "source_id", "generated_at", "computed_at", "data_status", "decision", "errors", @@ -5310,6 +5966,10 @@ export const __test = { normalizeAdaptiveSelectionSourceSnapshot, calculateAdaptiveSelectionDecisionDigest, emptyAdaptiveSelectionPayload, + normalizeM0ResearchLedgerTransport, + calculateM0ResearchLedgerSha256, + projectM0ResearchDashboardForRead, + emptyM0ResearchDashboardPayload, normalizeExecutionEvidenceSourceSnapshot, emptyExecutionEvidencePayload, calculateResearchTaskSha256,