From 3d4ae5ca527436ff7c774ad0684f21944dc3220b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:23:32 +0800 Subject: [PATCH 1/2] feat(console): Project bounded decision todo lifecycle --- docs/console-api.md | 39 ++++ tests/decision_cockpit_api_validation.mjs | 27 ++- tests/todo_lifecycle_validation.mjs | 99 ++++++++++ .../application/console_api/inbox.js | 10 +- .../application/console_api/todo_lifecycle.js | 69 +++++++ .../contracts/console-api.schema.json | 176 +++++++++++++++++- web/strategy-switch-console/worker.js | 34 +++- 7 files changed, 448 insertions(+), 6 deletions(-) create mode 100644 tests/todo_lifecycle_validation.mjs create mode 100644 web/strategy-switch-console/application/console_api/todo_lifecycle.js diff --git a/docs/console-api.md b/docs/console-api.md index 9b660d14..50aa8e16 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -199,6 +199,45 @@ A source failure remains visible in `sources` and produces inbox `status=read_failed`, even if other sources returned items. An unreadable history returns HTTP 503 with `ReadFailed`, never an empty success. +### Decision action todo lifecycle + +Inbox items and `GET /api/decisions/:id` reviews add `todo_lifecycle` +(`qsl.todo_lifecycle.v1`). Existing consumers may ignore the additive field; +existing eligibility, writes, IDs, pagination and runtime readback are unchanged. +This is the lifecycle of the **decision recording action**, with +`scope=decision_intent`. It does not complete application, setup effectiveness, +reconciliation, notification delivery or broker execution. + +| Field | Meaning | +|---|---| +| `owner` | Existing decision writers require `admin`. Person assignment is not stored by these sources: `assignment_status=not_recorded`, `actor_alias=null`. This is an explicit gap, never an inferred assignee. Credential maintenance has no supported inbox writer and a null role. | +| `state` | `pending`, `blocked`, `expired`, `acknowledged`, `unknown` or `unsupported`. A producer's decided state, applied status or notification does not establish acknowledgement. | +| `blockers` | Stable code and `resume_condition` for each observed blocker. Unknown source codes collapse to `decision_blocked_unknown`; raw producer text is omitted. Conditions describe evidence to restore, not execution authority. | +| `setup_decision` | Existing relative decision-page link plus exact decision ID, revision and material digest. Missing or inconsistent bindings return `unavailable` and null fields. Reload the current review before any existing action; the link itself grants no permission. | +| `completion` | `pending` means the exact current durable authority was read and has no decision. `acknowledged` requires the existing receipt integrity validator and matching kind, subject, full material and promotion review digest. `unknown` means missing, mismatched or unreadable evidence; credential actions are `unsupported`. Only pseudonymous actor, timestamp and receipt digest are exposed. | + +Resume conditions map stale evidence to `refresh_evidence`, expiry to +`refresh_decision_material`, missing authority to `restore_authority_read`, and +recovery blockers to `restore_recovery_readiness`. An already-decided source points +to `read_existing_decision_receipt`; an unknown completion points to +`restore_exact_decision_receipt`. Other invalid/ineligible material requires +`restore_verified_decision_material`. A credential action points to +`use_existing_maintenance_process`. These conditions never reopen a skipped period. + +Reads reuse current durable decision records and `humanDecisionReceipt`; there is +no new storage, completion mutation or task endpoint. The inbox validates only its +returned page, without cross-request caching. Receipt read failure preserves the +item, sets completion/state to `unknown`, and makes an otherwise healthy inbox +`partial`; existing source `read_failed` wins. Detail uses its own authority read +and validator and adds `decision_completion_unknown` to `errors` on failure. +Historical terminal decisions remain addressable through the existing by-ID +fallback. Repeated reads and a Worker restart reconstruct the same acknowledgement +from storage. Changed material or review bindings invalidate a projected ACK. +An acknowledged actor is evidence of who recorded the decision, not an assigned +owner. Actual owner assignment and broader work completion remain unrecorded; +this projection does not invent them. No frontend adoption or runtime acceptance +is implied by this backend contract. + ## Today, strategies and notification context `/api/runtime/today` chooses today's date on the server in `America/New_York`, diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs index 5fcfd203..40ef7e34 100644 --- a/tests/decision_cockpit_api_validation.mjs +++ b/tests/decision_cockpit_api_validation.mjs @@ -93,7 +93,7 @@ const options = { kvNamespaces: ["STRATEGY_SWITCH_CONFIG"], outboundService: () => new Response("offline only", { status: 503 }), }; -const mf = new Miniflare(options); +let mf = new Miniflare(options); async function initializeDirectory(runtime) { const namespace = await runtime.getDurableObjectNamespace("STRATEGY_SWITCH_RUNTIME_INSTANCES"); const response = await namespace.get(namespace.idFromName("console-kv-directory-v1")).fetch("https://fixture.invalid/kv-directory", { @@ -173,6 +173,9 @@ const ownerItem = inbox.body.items.find((item) => item.decision_id === "owner_de assert.ok(ownerItem, "owner decision item is present"); assert.match(ownerItem.revision, /^rev1-[0-9a-f]{16}$/); assert.equal(ownerItem.actionable, true); +assert.equal(ownerItem.todo_lifecycle.completion.status, "pending"); +assert.equal(ownerItem.todo_lifecycle.owner.assignment_status, "not_recorded"); +validate("todo_lifecycle", ownerItem.todo_lifecycle); assert.ok(ownerItem.expires_at, "actionable decisions must carry expiry"); assert.ok(ownerItem.allowed_actions.some((entry) => entry.action === "keep_parked")); assert.equal(ownerItem.request_id_required, true); @@ -263,6 +266,24 @@ assert.ok(decidedReview.body.current_eligibility.reason_codes.includes("decision assert.equal(decidedReview.body.decision.allowed_actions.length, 0); assert.equal(decidedReview.body.decision.decision_state.status, "decided"); validate("decision_review", decidedReview.body); +const todoAck = decidedReview.body.decision.todo_lifecycle; +assert.equal(todoAck.state, "acknowledged"); +assert.equal(todoAck.completion.receipt_sha256, decided.body.decision_receipt.receipt_sha256); +assert.match(todoAck.completion.actor_alias, /^actor-[0-9a-f]{16}$/); +assert.doesNotMatch(JSON.stringify(todoAck), /cockpit-admin/); +validate("todo_lifecycle", todoAck); +for (let attempt = 0; attempt < 2; attempt++) { + const queue = await call("/api/decisions-inbox"); + const same = queue.body.items.find(item => item.id === ownerItem.id); + assert.deepEqual(same.todo_lifecycle.completion, todoAck.completion); +} +// Reconstruct the same exact decision from durable storage with a fresh Worker. +await mf.dispose(); +mf = new Miniflare(options); +const restartedReview = await call(`/api/decisions/${encodeURIComponent(ownerItem.id)}`); +assert.equal(restartedReview.status, 200); +assert.deepEqual(restartedReview.body.decision.todo_lifecycle.completion, todoAck.completion); + // A new operation id is a new intent even when it repeats the recorded action. const otherOperationSameAction = await call(actionUrl, { method: "POST", body: { action: "keep_parked", expected_revision: ownerItem.revision, operation_id: "cockpit-request-other", @@ -618,4 +639,6 @@ assert.equal(missingRuntime.body.observed_at, null); assert.equal(missingRuntime.body.policy.execution_authority_granted, false); await mf.dispose(); -console.log("decision_cockpit_api_validation: PASS"); +console.log("decision_cockpit_api_validation: PASS (including durable todo ACK restart)"); + +await import("./todo_lifecycle_validation.mjs"); diff --git a/tests/todo_lifecycle_validation.mjs b/tests/todo_lifecycle_validation.mjs new file mode 100644 index 00000000..4fcc59f8 --- /dev/null +++ b/tests/todo_lifecycle_validation.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { inbox } from "../web/strategy-switch-console/application/console_api/inbox.js"; +const material = "a".repeat(64), review = "b".repeat(64); +const row = { ticket_id: "-todo", state: "awaiting_human", expires_at: "2099-01-01T00:00:00Z", + decision_binding: { kind: "promotion", subject_id: "-todo", material_sha256: material, review_binding: { review_sha256: review } }, + decision_authority: { material_sha256: material, eligible: true, expires_at: "2099-01-01T00:00:00Z" } }; +const request = new Request("https://fixture.invalid/api/decisions-inbox"); +const empty = async () => Response.json({ data_status: "ready", candidates: [], tickets: [], recoveries: [] }); +const ack = { status: "found", kind: "promotion", subject_id: row.ticket_id, material_sha256: material, review_sha256: review, + action: "accept", actor_alias: "actor-0123456789abcdef", acknowledged_at: "2030-01-01T00:00:00Z", receipt_sha256: "c".repeat(64) }; +async function read(entry = row, reader = async () => ({ status: "not_found" }), options) { + return inbox(request, {}, { owner: empty, recovery: empty, credential: empty, + promotion: async () => Response.json({ data_status: "ready", tickets: [entry] }), decisionCompletion: reader }, options); +} +const marked = { ...row, human_decision: "accept", state: "human_accepted", application: { status: "applied_paused" }, + completion: { status: "acknowledged", decided_by: "-private@example.invalid" }, blocker_codes: ["-private@example.invalid"] }; + +test("owner and revision-bound setup link", async () => { +const first = (await read()).items[0]; +assert.ok(first.todo_lifecycle, "existing list must expose lifecycle, not only decision fields"); +assert.equal(first.todo_lifecycle.owner.assignment_status, "not_recorded"); +assert.equal(first.todo_lifecycle.owner.required_role, "admin"); +assert.equal(first.todo_lifecycle.completion.status, "pending"); +assert.equal(first.todo_lifecycle.setup_decision.revision, first.revision); +assert.equal(first.todo_lifecycle.setup_decision.material_sha256, material); +assert.equal(first.todo_lifecycle.setup_decision.href, `?page=decisions&decision=${encodeURIComponent(first.id)}`); +}); +test("redaction and explicit resume conditions", async () => { +const unverified = (await read(marked)).items[0].todo_lifecycle; +assert.equal(unverified.completion.status, "pending"); +assert.notEqual(unverified.state, "acknowledged"); +assert.doesNotMatch(JSON.stringify(unverified), /private|example\.invalid|applied_paused/); +assert.ok(unverified.blockers.some(b => b.code === "decision_blocked_unknown" && b.resume_condition === "restore_verified_decision_material")); +const blocked = (await read({ ...row, blocker_codes: ["decision_evidence_stale", "recovery_blocked"] })).items[0].todo_lifecycle; +assert.ok(blocked.blockers.some(b => b.resume_condition === "refresh_evidence")); +assert.ok(blocked.blockers.some(b => b.resume_condition === "restore_recovery_readiness")); +const missing = (await read({ ...row, decision_binding: null })).items[0].todo_lifecycle; +assert.equal(missing.setup_decision.status, "unavailable"); +assert.equal(missing.setup_decision.href, null); +assert.equal(missing.completion.status, "unknown"); +}); +test("completion requires exact verified durable acknowledgement", async () => { +const completed = (await read(marked, async () => ack)).items[0].todo_lifecycle; +assert.equal(completed.state, "acknowledged"); +assert.equal(completed.completion.scope, "decision_intent"); +assert.equal(completed.completion.actor_alias, ack.actor_alias); +assert.equal(completed.completion.receipt_sha256, ack.receipt_sha256); +assert.equal(completed.execution_authority_granted, false); +for (const bad of [{ ...ack, material_sha256: "d".repeat(64) }, { ...ack, subject_id: "-other" }, + { ...ack, kind: "owner" }, { ...ack, review_sha256: "d".repeat(64) }, { ...ack, actor_alias: "-private@example.invalid" }, + { ...ack, receipt_sha256: null }, { ...ack, acknowledged_at: "invalid" }, { ...ack, action: "unexpected" }]) { + const result = await read(marked, async () => bad); + assert.equal(result.status, "partial"); + assert.equal(result.items[0].todo_lifecycle.completion.status, "unknown"); +} +for (const reader of [async () => { throw Error("-private@example.invalid"); }, async () => ({ status: "unavailable" }), async () => null]) { + const result = await read(marked, reader); + assert.equal(result.status, "partial"); + assert.equal(result.items.length, 1); + assert.equal(result.items[0].todo_lifecycle.state, "unknown"); + assert.doesNotMatch(JSON.stringify(result.items[0].todo_lifecycle), /private|example\.invalid/); +} +}); +test("current reads survive restart and authority drift without cached ACK", async () => { +let current = ack; +assert.equal((await read(marked, async () => current)).items[0].todo_lifecycle.completion.status, "acknowledged"); +current = { status: "not_found" }; +assert.equal((await read(marked, async () => current)).items[0].todo_lifecycle.completion.status, "pending"); +let calls = 0; +await read(row, async () => { calls++; return ack; }, { paginate: false }); +assert.equal(calls, 0, "by-id detail owns its own exact receipt read"); +}); + +test("bounded page reads and concurrent requests keep independent authority", async () => { + const rows = Array.from({ length: 30 }, (_, n) => ({ ...row, ticket_id: `-${n}`, + decision_binding: { ...row.decision_binding, subject_id: `-${n}` } })); + let reads = 0; + const queue = await inbox(new Request("https://fixture.invalid/api/decisions-inbox?limit=3"), {}, { + owner: empty, recovery: empty, credential: empty, + promotion: async () => Response.json({ data_status: "ready", tickets: rows }), + decisionCompletion: async () => { reads++; return { status: "not_found" }; }, + }); + assert.equal(reads, 3); + assert.equal(queue.items.length, 3); + const [found, missing] = await Promise.all([read(row, async () => ack), read(row, async () => ({ status: "not_found" }))]); + assert.equal(found.items[0].todo_lifecycle.completion.status, "acknowledged"); + assert.equal(missing.items[0].todo_lifecycle.completion.status, "pending"); +}); + +test("malformed blockers and mismatched revisions cannot leak or invent setup links", async () => { + const { todoLifecycle } = await import("../web/strategy-switch-console/application/console_api/todo_lifecycle.js"); + const item = (await read()).items[0]; + const result = todoLifecycle({ ...item, revision: "rev1-" + "0".repeat(16), + blocking_reason_codes: [{ toString: null }, "-private@example.invalid"] }, ack); + assert.equal(result.setup_decision.status, "unavailable"); + assert.equal(result.completion.status, "unknown"); + assert.doesNotMatch(JSON.stringify(result), /private|example\.invalid/); +}); diff --git a/web/strategy-switch-console/application/console_api/inbox.js b/web/strategy-switch-console/application/console_api/inbox.js index baf9ba6e..81516a62 100644 --- a/web/strategy-switch-console/application/console_api/inbox.js +++ b/web/strategy-switch-console/application/console_api/inbox.js @@ -1,3 +1,4 @@ +import { todoLifecycle, todoLifecycleItem } from "./todo_lifecycle.js"; import { canonicalItem } from "./presentation.js"; import { riskTierPolicyCatalog } from "./risk_tiers.js"; import { directoryStatus } from "../../kv_directory.js"; @@ -120,13 +121,18 @@ export async function inbox(request, env, deps, { paginate = true } = {}) { (a.created_at || "").localeCompare(b.created_at || "") || String(a.id ?? "").localeCompare(String(b.id ?? "")), ); - const items = page ? ordered.slice(page.offset, page.offset + page.limit) : ordered; + const selected = page ? ordered.slice(page.offset, page.offset + page.limit) : ordered; + const items = page + ? await Promise.all(selected.map(item => todoLifecycleItem(item, deps.decisionCompletion + ? current => deps.decisionCompletion(current, env) : null))) + : selected.map(item => ({ ...item, todo_lifecycle: todoLifecycle(item) })); + const completionUnknown = page && items.some(item => item.todo_lifecycle.completion.status === "unknown"); return { schema_version: "qsl.decision_inbox.v1", server_time: new Date().toISOString(), status: results.some((r) => r.status === "read_failed") ? "read_failed" - : results.some((r) => r.status !== "ok") + : completionUnknown || results.some((r) => r.status !== "ok") ? "partial" : "ok", error_state: failure?.error_state || null, diff --git a/web/strategy-switch-console/application/console_api/todo_lifecycle.js b/web/strategy-switch-console/application/console_api/todo_lifecycle.js new file mode 100644 index 00000000..8640f80f --- /dev/null +++ b/web/strategy-switch-console/application/console_api/todo_lifecycle.js @@ -0,0 +1,69 @@ +import { DECISION_ACTIONS, decisionCategoryPart, decisionKind, decisionRevision } from "./decision_contract.js"; + +const digest = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value) ? value : null; +const instant = value => typeof value === "string" && Number.isFinite(Date.parse(value)) ? value : null; +const RESUME = Object.freeze({ + decision_evidence_stale: "refresh_evidence", + decision_source_unavailable: "restore_source_read", + decision_not_actionable: "use_existing_maintenance_process", + decision_expiry_unavailable: "restore_verified_decision_material", + decision_expired: "refresh_decision_material", + recovery_blocked: "restore_recovery_readiness", + recovery_not_awaiting_confirmation: "restore_recovery_readiness", + decision_already_decided: "read_existing_decision_receipt", + decision_legacy_blocked: "restore_verified_decision_material", + decision_authority_unavailable: "restore_authority_read", + decision_material_ineligible: "restore_verified_decision_material", + decision_material_changed: "reload_current_revision", +}); + +/** Read-only decision action lifecycle; it never certifies downstream execution. */ +export function todoLifecycle(item, acknowledgement = { status: "unavailable" }) { + const parts = decisionCategoryPart(item.id); + const kind = decisionKind(item.category); + const binding = item.decision_binding; + const material = digest(binding?.material_sha256); + const review = digest(binding?.review_binding?.review_sha256); + const bound = !!(parts && kind && parts.category === item.category && binding?.kind === kind + && binding.subject_id === parts.subject_id && material && item.revision === decisionRevision(binding, item.id)); + const supported = !!kind; + const codes = Array.isArray(item.blocking_reason_codes) ? item.blocking_reason_codes : [null]; + const blockers = [...new Set(codes.slice(0, 20).map(code => typeof code === "string" && Object.hasOwn(RESUME, code) ? code : "decision_blocked_unknown"))] + .map(code => ({ code, resume_condition: RESUME[code] || "restore_verified_decision_material" })); + if (codes.length > 20 && !blockers.some(blocker => blocker.code === "decision_blocked_unknown")) + blockers.push({ code: "decision_blocked_unknown", resume_condition: "restore_verified_decision_material" }); + let status = supported ? "unknown" : "unsupported"; + const ack = acknowledgement; + if (bound && ack?.status === "not_found") status = "pending"; + if (bound && ack?.status === "found" && ack.kind === kind && ack.subject_id === parts.subject_id + && ack.material_sha256 === material && (ack.review_sha256 ?? null) === review + && DECISION_ACTIONS[item.category]?.includes(ack.action) && digest(ack.receipt_sha256) + && instant(ack.acknowledged_at) && /^actor-[0-9a-f]{16}$/.test(ack.actor_alias || "")) status = "acknowledged"; + const completion = { + status, scope: "decision_intent", + actor_alias: status === "acknowledged" ? ack.actor_alias : null, + acknowledged_at: status === "acknowledged" ? ack.acknowledged_at : null, + receipt_sha256: status === "acknowledged" ? ack.receipt_sha256 : null, + }; + if (status === "unknown") blockers.push({ code: "decision_completion_unknown", resume_condition: "restore_exact_decision_receipt" }); + return { + schema_version: "qsl.todo_lifecycle.v1", scope: "decision_intent", + owner: { required_role: supported ? "admin" : null, assignment_status: "not_recorded", actor_alias: null }, + state: status === "acknowledged" ? "acknowledged" : !supported ? "unsupported" + : status === "unknown" ? "unknown" : item.status === "expired" ? "expired" + : blockers.length || item.actionable !== true ? "blocked" : "pending", + blockers, + setup_decision: { status: bound ? "available" : "unavailable", + href: bound ? `?page=decisions&decision=${encodeURIComponent(item.id)}` : null, + decision_id: bound ? item.id : null, revision: bound ? item.revision : null, material_sha256: bound ? material : null }, + completion, no_order: true, execution_authority_granted: false, + }; +} + +/** No cache: only this returned page is enriched from current durable authority. */ +export async function todoLifecycleItem(item, readCompletion) { + let acknowledgement; + try { acknowledgement = readCompletion ? await readCompletion(item) : null; } + catch { acknowledgement = null; } + return { ...item, todo_lifecycle: todoLifecycle(item, acknowledgement) }; +} diff --git a/web/strategy-switch-console/contracts/console-api.schema.json b/web/strategy-switch-console/contracts/console-api.schema.json index dd9b99b0..fb472557 100644 --- a/web/strategy-switch-console/contracts/console-api.schema.json +++ b/web/strategy-switch-console/contracts/console-api.schema.json @@ -534,6 +534,9 @@ }, "presentation": { "$ref": "#/$defs/recovery_material_presentation" + }, + "todo_lifecycle": { + "$ref": "#/$defs/todo_lifecycle" } }, "required": [ @@ -4207,7 +4210,12 @@ "type": "string" }, "decision": { - "type": "object" + "type": "object", + "properties": { + "todo_lifecycle": { + "$ref": "#/$defs/todo_lifecycle" + } + } }, "current_eligibility": { "$ref": "#/$defs/decision_eligibility" @@ -5082,6 +5090,172 @@ "operation_id", "outcome" ] + }, + "todo_lifecycle": { + "type": "object", + "properties": { + "schema_version": { + "const": "qsl.todo_lifecycle.v1" + }, + "scope": { + "const": "decision_intent" + }, + "owner": { + "type": "object", + "properties": { + "required_role": { + "enum": [ + "admin", + null + ] + }, + "assignment_status": { + "const": "not_recorded" + }, + "actor_alias": { + "type": "null" + } + }, + "required": [ + "required_role", + "assignment_status", + "actor_alias" + ], + "additionalProperties": false + }, + "state": { + "enum": [ + "pending", + "blocked", + "expired", + "acknowledged", + "unknown", + "unsupported" + ] + }, + "blockers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "resume_condition": { + "type": "string" + } + }, + "required": [ + "code", + "resume_condition" + ], + "additionalProperties": false + } + }, + "setup_decision": { + "type": "object", + "properties": { + "status": { + "enum": [ + "available", + "unavailable" + ] + }, + "href": { + "type": [ + "string", + "null" + ] + }, + "decision_id": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "type": [ + "string", + "null" + ] + }, + "material_sha256": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status", + "href", + "decision_id", + "revision", + "material_sha256" + ], + "additionalProperties": false + }, + "completion": { + "type": "object", + "properties": { + "status": { + "enum": [ + "pending", + "acknowledged", + "unknown", + "unsupported" + ] + }, + "scope": { + "const": "decision_intent" + }, + "actor_alias": { + "type": [ + "string", + "null" + ] + }, + "acknowledged_at": { + "type": [ + "string", + "null" + ] + }, + "receipt_sha256": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status", + "scope", + "actor_alias", + "acknowledged_at", + "receipt_sha256" + ], + "additionalProperties": false + }, + "no_order": { + "const": true + }, + "execution_authority_granted": { + "const": false + } + }, + "required": [ + "schema_version", + "scope", + "owner", + "state", + "blockers", + "setup_decision", + "completion", + "no_order", + "execution_authority_granted" + ], + "additionalProperties": false } } } diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 954ccd36..67744cf4 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -1,3 +1,4 @@ +import { todoLifecycle } from "./application/console_api/todo_lifecycle.js"; import { performanceBenchmarkResponse } from "./application/console_api/performance_benchmarks.js"; import { binanceDetailCommand } from "./binance_detail_archive.js"; import { createDeskAuth, DeskError, deskErrorResponse } from "./application/desk/http.js"; @@ -859,6 +860,7 @@ export default { json, readSession: today ? readTodaySession : readSession, command: runtimeInstanceCommand, accountOptions: today ? readTodayOptions : loadAccountOptionsConfig, settings: accountSettingsResponse, saveDraft: saveCombinedAccountDraft, + decisionCompletion: readTodoDecisionCompletion, owner: ownerDecisionQueueResponse, promotion: researchPromotionTicketsResponse, recovery: reconciliationRecoveryResponse, credential: (req, e) => credentialMaintenanceResponse(req, e, new URL("/api/credential-maintenance", req.url)), audit: loadAuditLog, @@ -12990,6 +12992,7 @@ const DECISION_POLICY = Object.freeze({ function decisionInboxSources() { return { + decisionCompletion: readTodoDecisionCompletion, owner: ownerDecisionQueueResponse, promotion: researchPromotionTicketsResponse, recovery: reconciliationRecoveryResponse, @@ -13131,6 +13134,30 @@ async function readDecisionAuthorityOrNull(env, kind, subjectId) { return readHumanDecisionAuthority(env, kind, subjectId); } +// Reuse the existing receipt integrity validator; never expose raw actors/targets. +async function readTodoDecisionCompletion(item, env, loadedAuthority = undefined, validatedReceipt = undefined) { + const kind = decisionKind(item.category); + if (!kind) return { status: "unsupported" }; + const binding = item.decision_binding; + if (!binding || binding.kind !== kind || !binding.subject_id || !/^[0-9a-f]{64}$/.test(binding.material_sha256 || "")) + return { status: "unavailable" }; + const authority = loadedAuthority !== undefined ? loadedAuthority + : await readDecisionAuthorityOrNull(env, kind, binding?.subject_id); + if (authority?.ok !== true || authority.migrated !== true || authority.material_key !== binding?.material_sha256) + return { status: "unavailable" }; + const expectedReview = binding?.review_binding?.review_sha256 ?? null; + if ((authority.review_binding?.review_sha256 ?? null) !== expectedReview) return { status: "unavailable" }; + if (!authority.decision) return { status: "not_found" }; + const receipt = validatedReceipt !== undefined ? validatedReceipt : await humanDecisionReceipt(authority.decision, { + kind, subject_id: binding.subject_id, material_sha256: binding.material_sha256, + }); + if (!receipt) return { status: "unavailable" }; + return { status: "found", kind: receipt.kind, subject_id: receipt.subject_id, + material_sha256: receipt.material_sha256, review_sha256: receipt.review_binding?.review_sha256 ?? null, + action: receipt.action, acknowledged_at: receipt.decided_at, receipt_sha256: receipt.receipt_sha256, + actor_alias: await actorAlias(receipt.decided_by) }; +} + async function decisionReviewResponse(request, env, rawId) { if (request.method !== "GET") return json({ ok: false, error: "method_not_allowed" }, 405); const session = await readSession(request, env); @@ -13167,6 +13194,11 @@ async function decisionReviewResponse(request, env, rawId) { errors.push("decision_receipt_unavailable"); } } + let acknowledgement; + try { acknowledgement = await readTodoDecisionCompletion(item, env, authorityRaw, receipt); } + catch { acknowledgement = { status: "unavailable" }; } + const lifecycle = todoLifecycle(item, acknowledgement); + if (lifecycle.completion.status === "unknown") errors.push("decision_completion_unknown"); if (authorityRaw?.migrated === false) errors.push("decision_authority_unavailable"); if (queue.status !== "ok") errors.push("decision_source_partial"); return json({ @@ -13174,7 +13206,7 @@ async function decisionReviewResponse(request, env, rawId) { server_time: new Date(now).toISOString(), data_status: errors.length ? "partial" : "ready", decision_id: item.id, - decision: item, + decision: { ...item, todo_lifecycle: lifecycle }, current_eligibility: decisionCurrentEligibility(item, authority, now), decision_authority: authority, decision_receipt: receipt, From 620411296261e119ba39a00873d180e599cba4dd Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:54:45 +0800 Subject: [PATCH 2/2] fix(console): Require promotion review digest before acknowledgement --- docs/console-api.md | 3 ++ tests/todo_lifecycle_validation.mjs | 30 +++++++++++++++++++ .../application/console_api/todo_lifecycle.js | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/console-api.md b/docs/console-api.md index 50aa8e16..a4637c43 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -233,6 +233,9 @@ and validator and adds `decision_completion_unknown` to `errors` on failure. Historical terminal decisions remain addressable through the existing by-ID fallback. Repeated reads and a Worker restart reconstruct the same acknowledgement from storage. Changed material or review bindings invalidate a projected ACK. +Promotion acknowledgement requires a valid 64-character lowercase hexadecimal +review digest. Historical/v1 receipts with missing, invalid or null review digests +remain `unknown`, including a null/null comparison; they do not prove completion. An acknowledged actor is evidence of who recorded the decision, not an assigned owner. Actual owner assignment and broader work completion remain unrecorded; this projection does not invent them. No frontend adoption or runtime acceptance diff --git a/tests/todo_lifecycle_validation.mjs b/tests/todo_lifecycle_validation.mjs index 4fcc59f8..59657851 100644 --- a/tests/todo_lifecycle_validation.mjs +++ b/tests/todo_lifecycle_validation.mjs @@ -97,3 +97,33 @@ test("malformed blockers and mismatched revisions cannot leak or invent setup li assert.equal(result.completion.status, "unknown"); assert.doesNotMatch(JSON.stringify(result), /private|example\.invalid/); }); + +test("promotion acknowledgement rejects unqualified historical review digests", async () => { + const { todoLifecycle } = await import("../web/strategy-switch-console/application/console_api/todo_lifecycle.js"); + const { decisionRevision } = await import("../web/strategy-switch-console/application/console_api/decision_contract.js"); + const current = (await read()).items[0]; + for (const category of ["strategy_promotion", "paper_account_application"]) { + for (const reviewBinding of [undefined, null, {}, { review_sha256: null }, + { review_sha256: "invalid" }, { review_sha256: "b".repeat(63) }]) { + const binding = { ...row.decision_binding, review_binding: reviewBinding }; + const id = `${category}:${row.ticket_id}`; + const item = { ...current, id, category, decision_binding: binding, revision: decisionRevision(binding, id) }; + for (const receiptReview of [undefined, null, reviewBinding?.review_sha256]) { + const result = todoLifecycle(item, { ...ack, review_sha256: receiptReview }); + assert.equal(result.completion.status, "unknown", `${category}: unqualified digest must not ACK`); + assert.equal(result.state, "unknown"); + assert.equal(result.completion.actor_alias, null); + assert.equal(result.completion.receipt_sha256, null); + assert.ok(result.blockers.some(b => b.code === "decision_completion_unknown")); + } + } + const qualified = { ...current, id: `${category}:${row.ticket_id}`, category }; + assert.equal(todoLifecycle(qualified, ack).completion.status, "acknowledged"); + for (const receiptReview of [undefined, null, "invalid", "d".repeat(64)]) + assert.equal(todoLifecycle(qualified, { ...ack, review_sha256: receiptReview }).completion.status, "unknown"); + } + const legacy = { ...row, decision_binding: { ...row.decision_binding, review_binding: null } }; + const result = await read(legacy, async () => ({ ...ack, review_sha256: null })); + assert.equal(result.status, "partial"); + assert.equal(result.items[0].todo_lifecycle.completion.status, "unknown"); +}); diff --git a/web/strategy-switch-console/application/console_api/todo_lifecycle.js b/web/strategy-switch-console/application/console_api/todo_lifecycle.js index 8640f80f..4f58be01 100644 --- a/web/strategy-switch-console/application/console_api/todo_lifecycle.js +++ b/web/strategy-switch-console/application/console_api/todo_lifecycle.js @@ -35,7 +35,7 @@ export function todoLifecycle(item, acknowledgement = { status: "unavailable" }) let status = supported ? "unknown" : "unsupported"; const ack = acknowledgement; if (bound && ack?.status === "not_found") status = "pending"; - if (bound && ack?.status === "found" && ack.kind === kind && ack.subject_id === parts.subject_id + if (bound && (kind !== "promotion" || review !== null) && ack?.status === "found" && ack.kind === kind && ack.subject_id === parts.subject_id && ack.material_sha256 === material && (ack.review_sha256 ?? null) === review && DECISION_ACTIONS[item.category]?.includes(ack.action) && digest(ack.receipt_sha256) && instant(ack.acknowledged_at) && /^actor-[0-9a-f]{16}$/.test(ack.actor_alias || "")) status = "acknowledged";