From f26ea286f4c2d39a18d3251b81867328e0b7ef5d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:44:14 +0800 Subject: [PATCH 01/13] feat(contracts): add decision cockpit API schemas Adds qsl.decision_review.v1, qsl.operation.v1, audit-event and risk-tier binding definitions plus the decision inbox server_time fields the new read endpoints return. --- .../contracts/console-api.schema.json | 815 +++++++++++++++++- 1 file changed, 814 insertions(+), 1 deletion(-) diff --git a/web/strategy-switch-console/contracts/console-api.schema.json b/web/strategy-switch-console/contracts/console-api.schema.json index 5c6ef8e4..ba44118c 100644 --- a/web/strategy-switch-console/contracts/console-api.schema.json +++ b/web/strategy-switch-console/contracts/console-api.schema.json @@ -189,6 +189,315 @@ "type": "string", "pattern": "^(IN|CH)-[0-9A-Z]{13}$" }, + "decision_id": { + "type": "string" + }, + "revision": { + "type": "string", + "pattern": "^rev[0-9]-[0-9a-z-]{1,64}$" + }, + "transition": { + "type": "object", + "properties": { + "axis": { + "type": "string" + }, + "from": { + "type": [ + "string", + "null" + ] + }, + "to": { + "type": [ + "string", + "null" + ] + }, + "pending_to": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "axis", + "from", + "to", + "pending_to" + ] + }, + "actionable": { + "type": "boolean" + }, + "blocking_reason_codes": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowed_actions": { + "type": "array", + "items": { + "$ref": "#/$defs/decision_action" + } + }, + "request_id_required": { + "type": "boolean" + }, + "action_endpoint": { + "type": [ + "string", + "null" + ] + }, + "decision_state": { + "type": "object", + "properties": { + "status": { + "enum": [ + "pending", + "decided" + ] + }, + "action": { + "type": [ + "string", + "null" + ] + }, + "decided_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status", + "action", + "decided_at" + ] + }, + "risk_impact": { + "type": "object", + "properties": { + "affects_live": { + "type": "boolean" + }, + "tier": { + "type": [ + "string", + "null" + ] + }, + "tier_label_key": { + "type": [ + "string", + "null" + ] + }, + "policy": { + "type": [ + "object", + "null" + ] + }, + "execution_authority_granted": { + "const": false + } + }, + "required": [ + "affects_live", + "tier", + "tier_label_key", + "policy", + "execution_authority_granted" + ] + }, + "evidence_summary": { + "type": "object", + "properties": { + "material_sha256": { + "type": [ + "string", + "null" + ] + }, + "review_sha256": { + "type": [ + "string", + "null" + ] + }, + "candidate_sha256": { + "type": [ + "string", + "null" + ] + }, + "facts_sha256": { + "type": [ + "string", + "null" + ] + }, + "dual_review_binding_sha256": { + "type": [ + "string", + "null" + ] + }, + "observed_at": { + "type": [ + "string", + "null" + ] + }, + "first_observed_at": { + "type": [ + "string", + "null" + ] + }, + "sample_count": { + "type": [ + "integer", + "null" + ] + }, + "dual_review": { + "type": [ + "object", + "null" + ] + }, + "shadow": { + "type": [ + "object", + "null" + ] + }, + "source_id": { + "type": [ + "string", + "null" + ] + }, + "freshness": { + "type": "string" + } + }, + "required": [ + "material_sha256", + "review_sha256", + "candidate_sha256", + "facts_sha256", + "dual_review_binding_sha256", + "observed_at", + "first_observed_at", + "sample_count", + "dual_review", + "shadow", + "source_id", + "freshness" + ] + }, + "operation_reference": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "enum": [ + "unapplied", + "accepted", + "applying", + "applied", + "failed", + "unknown" + ] + }, + "activation_status": { + "type": "string" + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "status", + "activation_status", + "reason_code", + "updated_at" + ] + } + ] + }, + "decision_authority": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "material_sha256": { + "type": [ + "string", + "null" + ] + }, + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "eligible": { + "type": [ + "boolean", + "null" + ] + }, + "blocked": { + "type": "boolean" + }, + "unavailable": { + "type": "boolean" + }, + "source": { + "type": "string" + } + }, + "required": [ + "material_sha256", + "expires_at", + "eligible", + "blocked", + "unavailable", + "source" + ] + } + ] + }, "presentation": { "$ref": "#/$defs/recovery_material_presentation" } @@ -207,7 +516,20 @@ "status", "affects_live", "diff", - "short_id" + "short_id", + "decision_id", + "revision", + "transition", + "actionable", + "blocking_reason_codes", + "allowed_actions", + "request_id_required", + "action_endpoint", + "decision_state", + "risk_impact", + "evidence_summary", + "operation_reference", + "decision_authority" ] } }, @@ -725,6 +1047,12 @@ "effective": { "type": "object" }, + "risk": { + "type": "object" + }, + "tier_binding": { + "$ref": "#/$defs/risk_tier_binding" + }, "adopted": { "const": false }, @@ -3678,6 +4006,491 @@ ] } } + }, + "decision_action": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "label_key": { + "type": "string" + }, + "requires_selection": { + "type": "boolean" + } + }, + "required": [ + "action", + "label_key", + "requires_selection" + ] + }, + "decision_eligibility": { + "type": "object", + "properties": { + "eligible": { + "type": "boolean" + }, + "material_matches": { + "type": [ + "boolean", + "null" + ] + }, + "expired": { + "type": "boolean" + }, + "blocked": { + "type": "boolean" + }, + "reason_codes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "eligible", + "material_matches", + "expired", + "blocked", + "reason_codes" + ] + }, + "decision_review": { + "type": "object", + "properties": { + "schema_version": { + "const": "qsl.decision_review.v1" + }, + "server_time": { + "type": "string" + }, + "data_status": { + "enum": [ + "ready", + "partial" + ] + }, + "decision_id": { + "type": "string" + }, + "decision": { + "type": "object" + }, + "current_eligibility": { + "$ref": "#/$defs/decision_eligibility" + }, + "decision_authority": { + "type": [ + "object", + "null" + ] + }, + "decision_receipt": { + "type": [ + "object", + "null" + ] + }, + "operation": { + "type": [ + "object", + "null" + ] + }, + "policy": { + "type": "object" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "schema_version", + "server_time", + "data_status", + "decision_id", + "decision", + "current_eligibility", + "decision_authority", + "decision_receipt", + "operation", + "policy", + "errors" + ] + }, + "decision_action_result": { + "type": "object", + "properties": { + "schema_version": { + "const": "qsl.decision_action_result.v1" + }, + "ok": { + "type": "boolean" + }, + "replayed": { + "type": "boolean" + }, + "decision_id": { + "type": "string" + }, + "revision": { + "type": "string", + "pattern": "^rev[0-9]-[0-9a-z-]{1,64}$" + }, + "action": { + "type": "string" + }, + "decision_receipt": { + "type": [ + "object", + "null" + ] + }, + "decided_at": { + "type": [ + "string", + "null" + ] + }, + "decision_state": { + "type": "object" + }, + "operation": { + "type": [ + "object", + "null" + ] + }, + "policy": { + "type": "object" + }, + "server_time": { + "type": "string" + }, + "error": { + "type": "string" + }, + "blocking_reason_codes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "schema_version", + "server_time" + ] + }, + "operation": { + "type": "object", + "properties": { + "schema_version": { + "const": "qsl.operation.v1" + }, + "server_time": { + "type": "string" + }, + "data_status": { + "type": "string" + }, + "operation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "decision_id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "subject_id": { + "type": "string" + }, + "status": { + "enum": [ + "unaccepted", + "unapplied", + "accepted", + "applying", + "applied", + "failed", + "unknown" + ] + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "activation_status": { + "type": "string" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "dispatch": { + "type": [ + "object", + "null" + ] + }, + "application": { + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "id", + "decision_id", + "kind", + "subject_id", + "status", + "reason_code", + "activation_status", + "updated_at", + "dispatch", + "application" + ] + }, + "decision": { + "type": [ + "object", + "null" + ] + }, + "policy": { + "type": "object" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "schema_version", + "server_time", + "data_status", + "operation", + "decision", + "policy", + "errors" + ] + }, + "audit_events": { + "type": "object", + "properties": { + "schema_version": { + "const": "qsl.audit_events.v1" + }, + "server_time": { + "type": "string" + }, + "data_status": { + "enum": [ + "ready", + "partial" + ] + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "source": { + "enum": [ + "durable_human_decision", + "research_promotion_application", + "account_settings_application", + "bounded_audit_log" + ] + }, + "occurred_at": { + "type": "string" + }, + "action": { + "type": "string" + }, + "actor_alias": { + "type": [ + "string", + "null" + ] + }, + "subject": { + "type": [ + "object", + "null" + ] + }, + "revision": { + "type": [ + "string", + "null" + ] + }, + "outcome": { + "type": [ + "string", + "null" + ] + }, + "ref": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "source", + "occurred_at", + "action", + "actor_alias", + "subject", + "revision", + "outcome", + "ref" + ] + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + }, + "coverage": { + "type": "string" + }, + "policy": { + "type": "object" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "schema_version", + "server_time", + "data_status", + "events", + "next_cursor", + "coverage", + "policy", + "errors" + ] + }, + "risk_tier_binding": { + "type": "object", + "properties": { + "schema_version": { + "const": "qsl.risk_tier_binding.v1" + }, + "scope_id": { + "type": [ + "string", + "null" + ] + }, + "platform": { + "type": [ + "string", + "null" + ] + }, + "target_name": { + "type": [ + "string", + "null" + ] + }, + "selected": { + "type": [ + "object", + "null" + ] + }, + "approved": { + "type": [ + "object", + "null" + ] + }, + "effective": { + "type": "object", + "properties": { + "tier": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "unavailable_reason": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "tier", + "status", + "unavailable_reason", + "source" + ] + }, + "no_order": { + "const": true + }, + "execution_authority_granted": { + "const": false + } + }, + "required": [ + "schema_version", + "scope_id", + "platform", + "target_name", + "selected", + "approved", + "effective", + "no_order", + "execution_authority_granted" + ] } } } From 4f80a28d1632cf3903a8ffbaf6f67c3e13a604fc Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:44:19 +0800 Subject: [PATCH 02/13] feat(console): add decision contract and risk tier projections Extracts decision identity, revision, transition, actionability and receipt fields into decision_contract.js, adds closed risk-tier policy bindings, and projects them onto canonical inbox items. Inbox payloads now carry schema_version and server_time. --- .../application/approval/owner_decisions.js | 1 + .../console_api/decision_contract.js | 290 ++++++++++++++++++ .../application/console_api/inbox.js | 2 + .../application/console_api/presentation.js | 67 +++- .../application/console_api/risk_tiers.js | 71 +++++ 5 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 web/strategy-switch-console/application/console_api/decision_contract.js create mode 100644 web/strategy-switch-console/application/console_api/risk_tiers.js diff --git a/web/strategy-switch-console/application/approval/owner_decisions.js b/web/strategy-switch-console/application/approval/owner_decisions.js index 4169ed46..bf8364de 100644 --- a/web/strategy-switch-console/application/approval/owner_decisions.js +++ b/web/strategy-switch-console/application/approval/owner_decisions.js @@ -71,6 +71,7 @@ export async function ownerDecisionQueueResponse(request, env, deps) { candidate, candidate_evidence_sha256: candidateEvidenceSha256, decision_binding: { kind: "owner", subject_id: candidate.candidate_id, material_sha256: candidateEvidenceSha256 }, + decision_authority: stored.authority || null, intent, }); } diff --git a/web/strategy-switch-console/application/console_api/decision_contract.js b/web/strategy-switch-console/application/console_api/decision_contract.js new file mode 100644 index 00000000..a112f66a --- /dev/null +++ b/web/strategy-switch-console/application/console_api/decision_contract.js @@ -0,0 +1,290 @@ +import { riskTierPolicy } from "./risk_tiers.js"; + +/** Stable decision review projection shared by the inbox and /api/decisions. */ +export const DECISION_REVIEW_SCHEMA_VERSION = "qsl.decision_review.v1"; +export const DECISION_CATEGORIES = Object.freeze([ + "reconciliation_recovery", + "strategy_promotion", + "owner_decision", + "credential_maintenance", + "paper_account_application", +]); +export const DECISION_ACTIONS = Object.freeze({ + owner_decision: Object.freeze(["approve_limited_live_canary", "keep_parked"]), + reconciliation_recovery: Object.freeze(["approve", "reject"]), + strategy_promotion: Object.freeze(["accept", "reject"]), + paper_account_application: Object.freeze(["accept", "reject"]), + credential_maintenance: Object.freeze([]), +}); +export const DECISION_ACTION_LABELS = Object.freeze({ + approve_limited_live_canary: "decisions.action.approve_limited_live_canary", + keep_parked: "decisions.action.keep_parked", + approve: "decisions.action.approve", + reject: "decisions.action.reject", + accept: "decisions.action.accept", +}); +const DECISION_KIND = Object.freeze({ + owner_decision: "owner", + strategy_promotion: "promotion", + paper_account_application: "promotion", + reconciliation_recovery: "recovery", +}); +const DECISION_WRITE_FROM_CATEGORY = Object.freeze({ + owner_decision: "owner_decision", + strategy_promotion: "strategy_promotion", + paper_account_application: "strategy_promotion", + reconciliation_recovery: "reconciliation_recovery", +}); +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; + +export function decisionCategoryPart(id) { + const match = /^(owner_decision|strategy_promotion|paper_account_application|reconciliation_recovery|credential_maintenance):(.+)$/.exec(String(id || "")); + if (!match || match[2].length > 256 || /[\u0000-\u001f\u007f]/.test(match[2])) return null; + return { category: match[1], subject_id: match[2] }; +} + +export function decisionKind(category) { + return DECISION_KIND[category] || null; +} + +/** The legacy write category that owns the durable decision record. */ +export function decisionWriteCategory(category) { + return DECISION_WRITE_FROM_CATEGORY[category] || category; +} + +export function decisionSubjectId(category, data, entry) { + if (category === "credential_maintenance") return data.id || null; + if (category === "reconciliation_recovery") return data.recovery_id || entry.recovery_id || null; + if (category === "owner_decision") return data.candidate_id || null; + return data.ticket_id || entry.ticket_id || null; +} + +export function decisionIdFor(category, subjectId) { + return subjectId ? `${category}:${subjectId}` : null; +} + +/** Synchronous, opaque material revision. The server still matches the full digest. */ +export function decisionRevision(binding, fallbackId) { + const material = digest(binding?.material_sha256); + if (material) { + const review = digest(binding?.review_binding?.review_sha256); + return `rev1-${material.slice(0, 16)}${review ? `-${review.slice(0, 8)}` : ""}`; + } + let hash = 2166136261; + for (const byte of new TextEncoder().encode(String(fallbackId || ""))) + hash = Math.imul(hash ^ byte, 16777619) >>> 0; + return `rev0-${hash.toString(16).padStart(8, "0")}`; +} + +export function decisionExpiry(entry, data) { + const authority = entry?.decision_authority; + if (authority && Object.prototype.hasOwnProperty.call(authority, "expires_at")) + return instant(authority.expires_at); + return instant(data?.expires_at) || instant(entry?.expires_at); +} + +export function decisionExpiryState(expiresAt, now = Date.now()) { + if (!expiresAt) return "unavailable"; + const parsed = Date.parse(expiresAt); + if (!Number.isFinite(parsed)) return "unavailable"; + return now >= parsed ? "expired" : "active"; +} + +export function decisionRecorded(entry, data, category) { + if (category === "credential_maintenance") return false; + return Boolean( + entry?.intent || + entry?.confirmation || + entry?.rejection || + data?.human_decision || + ["human_accepted", "human_rejected"].includes(data?.state) || + entry?.application, + ); +} + +export function decisionRecordAction(entry, data, category) { + if (entry?.intent?.decision) return entry.intent.decision; + if (entry?.rejection) return "reject"; + if (entry?.confirmation) return "approve"; + if (data?.human_decision) return data.human_decision; + if (entry?.application || data?.state === "human_accepted") return "accept"; + if (data?.state === "human_rejected") return "reject"; + return null; +} + +export function decisionRecordDecidedAt(entry, data) { + return instant( + entry?.intent?.decided_at || + entry?.confirmation?.confirmed_at || + entry?.rejection?.decided_at || + data?.human_decided_at || + entry?.application?.updated_at, + ); +} + +export function decisionTransition(category, data, entry, action = null) { + const target = category === "paper_account_application" ? "paper" : "small_live"; + if (category === "owner_decision") + return { + axis: "authorized_stage", + from: data?.lifecycle?.stage || "P6", + to: action === "approve_limited_live_canary" ? "small_live_intent" : action === "keep_parked" ? "research" : (data?.lifecycle?.stage || "P6"), + pending_to: "small_live_intent", + }; + if (category === "strategy_promotion" || category === "paper_account_application") + return { + axis: "authorized_stage", + from: "research", + to: action === "accept" ? `${target}_intent` : action === "reject" ? "research" : "research", + pending_to: `${target}_intent`, + }; + if (category === "reconciliation_recovery") { + const enrollment = data?.mode === "initial_enrollment"; + return { + axis: "operating_state", + from: enrollment ? "UNENROLLED" : (data?.reconciliation_state || "RECONCILE_ONLY"), + to: action === "approve" ? (enrollment ? "ACCOUNT_ENROLLED_INTENT" : "ACTIVE_LKG_INTENT") : action === "reject" ? "PAUSED" : (enrollment ? "UNENROLLED" : (data?.reconciliation_state || "RECONCILE_ONLY")), + pending_to: enrollment ? "ACCOUNT_ENROLLED_INTENT" : "ACTIVE_LKG_INTENT", + }; + } + return { axis: "credential", from: "current", to: "current", pending_to: "rotated" }; +} + +export function decisionActionability(entry, data, category, now = Date.now()) { + const blockers = []; + const freshness = entry?.freshness?.data_status || entry?.freshness?.status || data?.freshness?.status || "unknown"; + if (["stale", "expired"].includes(freshness)) blockers.push("decision_evidence_stale"); + if (["unavailable", "read_failed"].includes(entry?.source_status)) blockers.push("decision_source_unavailable"); + const supported = DECISION_ACTIONS[category] || []; + if (!supported.length) blockers.push("decision_not_actionable"); + const expiresAt = decisionExpiry(entry, data); + if (supported.length && !expiresAt) blockers.push("decision_expiry_unavailable"); + else if (expiresAt && decisionExpiryState(expiresAt, now) !== "active") blockers.push("decision_expired"); + if (category === "reconciliation_recovery" && data?.readiness && data.readiness !== "awaiting_human_confirmation") + blockers.push(data.readiness === "blocked" ? "recovery_blocked" : "recovery_not_awaiting_confirmation"); + if (decisionRecorded(entry, data, category)) blockers.push("decision_already_decided"); + if (Array.isArray(data?.blocker_codes)) blockers.push(...data.blocker_codes); + const authority = entry?.decision_authority; + if (authority?.blocked === true) blockers.push("decision_legacy_blocked"); + if (authority?.unavailable === true) blockers.push("decision_authority_unavailable"); + if (authority && authority.eligible === false && !blockers.includes("decision_expired") + && !blockers.includes("decision_material_ineligible")) blockers.push("decision_material_ineligible"); + return { actionable: blockers.length === 0, blocker_codes: [...new Set(blockers.filter(Boolean))] }; +} + +export function decisionAllowedActions(entry, data, category, actionability) { + if (!actionability.actionable || decisionRecorded(entry, data, category)) return []; + const supported = DECISION_ACTIONS[category] || []; + if (category === "reconciliation_recovery" && data?.readiness !== "awaiting_human_confirmation") return []; + if ((category === "strategy_promotion" || category === "paper_account_application") + && data?.state !== "awaiting_human") return []; + return [...supported]; +} + +export function decisionActionList(category, actions) { + return actions.map((action) => ({ + action, + label_key: DECISION_ACTION_LABELS[action] || `decisions.action.${action}`, + requires_selection: category === "strategy_promotion" || category === "paper_account_application" + ? action === "accept" : false, + })); +} + +export function decisionRiskImpact(category, data, affectsLive, tier = null) { + const selectedTier = ["CAPITAL_PRESERVATION", "BALANCED_COMPOUNDING", "GROWTH_COMPOUNDING"].includes(tier) ? tier : null; + return { + affects_live: affectsLive === true, + tier: selectedTier, + tier_label_key: selectedTier ? `risk.tier.${selectedTier}` : null, + policy: riskTierPolicy(selectedTier), + execution_authority_granted: false, + }; +} + +export function decisionEvidenceSummary(entry, data, category, binding) { + const dual = data?.dual_review || null; + return { + material_sha256: digest(binding?.material_sha256), + review_sha256: digest(binding?.review_binding?.review_sha256), + candidate_sha256: digest(data?.candidate_sha256), + facts_sha256: digest(data?.facts_sha256), + dual_review_binding_sha256: digest(dual?.evidence_binding_sha256), + observed_at: instant(data?.last_observed_at || data?.observed_at), + first_observed_at: instant(data?.first_observed_at), + sample_count: Number.isSafeInteger(data?.evidence_sample_count) ? data.evidence_sample_count : null, + dual_review: dual ? { + outcome: typeof dual.outcome === "string" && dual.outcome.length <= 40 ? dual.outcome : null, + reviewer_count: Number.isSafeInteger(dual.reviewer_count) ? dual.reviewer_count : null, + } : null, + shadow: category === "strategy_promotion" || category === "paper_account_application" ? { + passed: data?.shadow_passed === true, + evidence_kind: typeof data?.shadow_evidence_kind === "string" && data.shadow_evidence_kind.length <= 80 + ? data.shadow_evidence_kind : null, + } : null, + source_id: typeof entry?.source_id === "string" && entry.source_id.length <= 80 ? entry.source_id : null, + freshness: entry?.freshness?.data_status || entry?.freshness?.status || "unknown", + }; +} + +export function operationIdFor(category, subjectId) { + return subjectId ? `operation:${category}:${subjectId}` : null; +} + +export function operationStatusFromApplication(application) { + if (!application) return "unapplied"; + if (application.status === "approved") + return application.dispatch_state === "unknown" ? "unknown" : "accepted"; + if (application.status === "claimed") return "applying"; + if (application.status === "applied_paused") return "applied"; + if (application.status === "rejected") return "failed"; + if (application.status === "uncertain") return "unknown"; + return "unknown"; +} + +export function decisionOperationReference(entry, category, subjectId) { + const application = entry?.application || null; + const id = operationIdFor(category, subjectId); + if (!id) return null; + if (category === "strategy_promotion" || category === "paper_account_application") + return { + id, + status: operationStatusFromApplication(application), + activation_status: "not_connected", + reason_code: application ? (application.reason_code || null) : "application_not_created", + updated_at: instant(application?.updated_at), + }; + return { + id, + status: "unapplied", + activation_status: "not_connected", + reason_code: "application_not_connected", + updated_at: null, + }; +} + +export function decisionCurrentEligibility(item, authority, now = Date.now()) { + const expiresAt = decisionExpiry({ decision_authority: authority }, item); + const expiryState = decisionExpiryState(expiresAt, now); + const material = digest(item?.decision_binding?.material_sha256); + const authoritative = digest(authority?.material_sha256); + const materialMatches = material && authoritative ? material === authoritative : null; + const blockers = [...(item?.blocking_reason_codes || [])]; + if (item?.decision_state?.status === "decided") blockers.push("decision_already_decided"); + if (expiryState === "expired") blockers.push("decision_expired"); + if (expiryState === "unavailable" && (DECISION_ACTIONS[item?.category] || []).length) blockers.push("decision_expiry_unavailable"); + if (materialMatches === false) blockers.push("decision_material_changed"); + if (authority?.blocked === true) blockers.push("decision_legacy_blocked"); + if (authority?.eligible === false) blockers.push("decision_material_ineligible"); + const allowed = item?.actionable === true && item?.decision_state?.status !== "decided" + && expiryState === "active" && materialMatches !== false && authority?.blocked !== true && authority?.eligible !== false; + return { + eligible: allowed, + material_matches: materialMatches, + expired: expiryState === "expired", + blocked: authority?.blocked === true, + reason_codes: [...new Set(blockers.filter(Boolean))], + }; +} diff --git a/web/strategy-switch-console/application/console_api/inbox.js b/web/strategy-switch-console/application/console_api/inbox.js index 8dc64c18..c7aa61c0 100644 --- a/web/strategy-switch-console/application/console_api/inbox.js +++ b/web/strategy-switch-console/application/console_api/inbox.js @@ -71,6 +71,8 @@ export async function inbox(request, env, deps) { ); const priority = { high: 0, medium: 1, normal: 2 }; 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") diff --git a/web/strategy-switch-console/application/console_api/presentation.js b/web/strategy-switch-console/application/console_api/presentation.js index 33a7d0f8..014c0856 100644 --- a/web/strategy-switch-console/application/console_api/presentation.js +++ b/web/strategy-switch-console/application/console_api/presentation.js @@ -1,3 +1,20 @@ +import { + decisionActionability, + decisionActionList, + decisionAllowedActions, + decisionEvidenceSummary, + decisionExpiry, + decisionIdFor, + decisionOperationReference, + decisionRecordAction, + decisionRecordDecidedAt, + decisionRecorded, + decisionRevision, + decisionRiskImpact, + decisionSubjectId, + decisionTransition, +} from "./decision_contract.js"; + /** Display-safe projections. Legacy evidence payloads are never display text. */ export const SETTINGS_FIELDS = { strategy_profile: "strategy", @@ -46,7 +63,7 @@ export function settingsDiff(settings) { } export function decisionFields(entry, category, now = Date.now()) { const data = entry.candidate || entry.recovery || entry; - const expires = time(data.expires_at || entry.expires_at); + const expires = decisionExpiry(entry, data); const freshness = entry.freshness?.status || entry.freshness?.data_status || @@ -59,7 +76,7 @@ export function decisionFields(entry, category, now = Date.now()) { data.human_decision ); const expired = - (expires && Date.parse(expires) < now) || + (expires && Date.parse(expires) <= now) || ["stale", "expired"].includes(freshness); const diff = []; const row = (field, before, after, affects = category !== "paper_account_application") => { @@ -159,22 +176,60 @@ export function decorateQueue(payload, field, category) { export function canonicalItem(entry, category) { category = decisionCategory(entry, category); const data = entry.candidate || entry.recovery || entry; + const subjectId = decisionSubjectId(category, data, entry); const id = category === "credential_maintenance" ? `credential:${data.id}` - : `${category}:${data.candidate_id || data.recovery_id || data.ticket_id}`; + : decisionIdFor(category, subjectId) || `${category}:${data.candidate_id || data.recovery_id || data.ticket_id}`; + const binding = entry.decision_binding || null; + const fields = decisionFields(entry, category); + const actionability = decisionActionability(entry, data, category); + const recorded = decisionRecorded(entry, data, category); + const action = decisionRecordAction(entry, data, category); + const allowed = decisionAllowedActions(entry, data, category, actionability); + const authority = entry.decision_authority; + const tier = data.confirmation_risk_profile || data.suggested_risk_profile || null; return { id, short_id: shortId(id), - ...decisionFields(entry, category), + ...fields, ...(category === "paper_account_application" ? { - status: entry.application || entry.state === "human_accepted" ? "decided" : decisionFields(entry, category).status, + status: entry.application || entry.state === "human_accepted" ? "decided" : fields.status, application_status: entry.application?.status || null, } : {}), + decision_id: id, + revision: decisionRevision(binding, id), + transition: decisionTransition(category, data, entry, action), + actionable: actionability.actionable, + blocking_reason_codes: actionability.blocker_codes, + allowed_actions: decisionActionList(category, allowed), + request_id_required: allowed.length > 0, + action_endpoint: allowed.length + ? `/api/decisions/${encodeURIComponent(id)}/actions` + : null, + decision_state: { + status: recorded ? "decided" : "pending", + action: recorded ? action : null, + decided_at: recorded ? decisionRecordDecidedAt(entry, data) : null, + }, + risk_impact: decisionRiskImpact(category, data, fields.affects_live, tier), + evidence_summary: decisionEvidenceSummary(entry, data, category, binding), + operation_reference: decisionOperationReference(entry, category, subjectId), + decision_authority: authority ? { + material_sha256: + typeof authority.material_sha256 === "string" && /^[0-9a-f]{64}$/.test(authority.material_sha256) + ? authority.material_sha256 + : null, + expires_at: time(authority.expires_at), + eligible: typeof authority.eligible === "boolean" ? authority.eligible : null, + blocked: authority.blocked === true, + unavailable: authority.unavailable === true, + source: authority.source || "durable_human_decision_material", + } : null, title_key: `decisions.${category}`, account_alias: data.account_key || data.confirmation_account_key || null, platform: scalar(data.platform || data.confirmation_target_platform), - decision_binding: entry.decision_binding || null, + decision_binding: binding, ...(category === "reconciliation_recovery" && entry.presentation ? { presentation: entry.presentation } : {}), ...(category === "reconciliation_recovery" && data.mode === "initial_enrollment" ? { initial_enrollment: { facts: data.facts, candidate_sha256: data.candidate_sha256, facts_sha256: data.facts_sha256, readiness: data.readiness, blocker_codes: data.blocker_codes }, diff --git a/web/strategy-switch-console/application/console_api/risk_tiers.js b/web/strategy-switch-console/application/console_api/risk_tiers.js new file mode 100644 index 00000000..e330c357 --- /dev/null +++ b/web/strategy-switch-console/application/console_api/risk_tiers.js @@ -0,0 +1,71 @@ +/** Closed risk-tier policy bindings. These are owner intent, never execution authority. */ +export const RISK_TIER_POLICY_BINDINGS = Object.freeze({ + CAPITAL_PRESERVATION: Object.freeze({ + policy_id: "capital_preservation_v1", + profile_id: "capital_preservation_v1", + composer_mdd_multiple: 1.0, + promotion_size_scale: 0.5, + }), + BALANCED_COMPOUNDING: Object.freeze({ + policy_id: "balanced_compounding_v1", + profile_id: "balanced_compounding_v1", + composer_mdd_multiple: 1.25, + promotion_size_scale: 0.75, + }), + GROWTH_COMPOUNDING: Object.freeze({ + policy_id: "growth_compounding_v1", + profile_id: "growth_compounding_v1", + composer_mdd_multiple: 1.5, + promotion_size_scale: 1.0, + }), +}); + +export const RISK_TIER_BINDING_SCHEMA_VERSION = "qsl.risk_tier_binding.v1"; + +export function riskTierPolicy(tier) { + const policy = RISK_TIER_POLICY_BINDINGS[tier]; + return policy ? { + schema_version: "qsl.risk_tier_policy.v1", + tier, + policy_id: policy.policy_id, + profile_id: policy.profile_id, + composer_mdd_multiple: policy.composer_mdd_multiple, + promotion_size_scale: policy.promotion_size_scale, + executable: false, + activation_status: "not_connected", + } : null; +} + +export function riskTierBindingView({ scope_id, platform, target_name, binding, revision = null }) { + const tier = binding?.profile_selection?.risk_preference || null; + const policy = riskTierPolicy(tier); + return { + schema_version: RISK_TIER_BINDING_SCHEMA_VERSION, + scope_id: scope_id || binding?.scope_id || null, + platform: platform || binding?.platform || null, + target_name: target_name || binding?.target_name || null, + selected: binding && tier ? { + tier, + profile_id: binding.profile_selection.profile_id || policy?.profile_id || null, + revision, + binding_sha256: binding.binding_sha256 || null, + updated_at: binding.updated_at || null, + source: "risk_profile_binding", + } : null, + approved: policy ? { + tier, + profile_id: policy.profile_id, + policy_id: policy.policy_id, + policy, + source: "policy_binding", + } : null, + effective: { + tier: null, + status: "unavailable", + unavailable_reason: "effective_tier_not_observed", + source: null, + }, + no_order: true, + execution_authority_granted: false, + }; +} From f44797d11ea1a50c41dc41e88319ea518ce4355b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:44:23 +0800 Subject: [PATCH 03/13] feat(worker): expose decision cockpit read and action routes Adds GET /api/decisions/:id, POST /api/decisions/:id/actions, GET /api/operations/:id and GET /api/audit-events. Writes require the current revision and an Idempotency request id, reject expired or ineligible decisions, and replay identical requests instead of recording a second decision. --- web/strategy-switch-console/worker.js | 710 +++++++++++++++++++++++++- 1 file changed, 684 insertions(+), 26 deletions(-) diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index bea70133..5583e374 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -7,7 +7,14 @@ import { strategyTargetExecution, accountExecutionEvidence } from "./application import { performanceResponse } from "./application/console_api/performance_routes.js"; import { consoleApiResponse } from "./application/console_api/routes.js"; import { runControls } from "./application/console_api/run_controls.js"; -import { decorateQueue, decisionFields } from "./application/console_api/presentation.js"; +import { decorateQueue, decisionFields, actorAlias, pageQuery } from "./application/console_api/presentation.js"; +import { inbox } from "./application/console_api/inbox.js"; +import { + DECISION_ACTIONS, DECISION_REVIEW_SCHEMA_VERSION, decisionCategoryPart, decisionCurrentEligibility, + decisionExpiry, decisionExpiryState, decisionKind, decisionRevision, decisionWriteCategory, + operationIdFor, operationStatusFromApplication, +} from "./application/console_api/decision_contract.js"; +import { RISK_TIER_POLICY_BINDINGS, riskTierBindingView } from "./application/console_api/risk_tiers.js"; import { recoveryMaterialPresentation, currentReconciliationObservationPresentation } from "./application/console_api/reconciliation_presentation.js"; // deploy: 2026-06-30 — config driven by platform-config.json import { CredentialMaintenanceError, initializeCredentialMaintenance, credentialMaintenanceCommand } from "./credentialMaintenance.js"; @@ -179,6 +186,11 @@ const RISK_PROFILE_IDS = { GROWTH_COMPOUNDING: "growth_compounding_v1", }; const RISK_PROFILE_PREFERENCES = Object.keys(RISK_PROFILE_IDS); +const DECISION_ACTION_SCHEMA_VERSION = "qsl.decision_action_result.v1"; +const OPERATION_SCHEMA_VERSION = "qsl.operation.v1"; +const AUDIT_EVENTS_SCHEMA_VERSION = "qsl.audit_events.v1"; +const DECISION_ACTION_MAX_BODY_BYTES = 8 * 1024; +const AUDIT_EVENTS_MAX_ROWS = 1000; const AUDIT_LOG_KEY = "audit_log"; const AUDIT_LOG_LIMIT = 50; const CURRENT_STRATEGIES_TIMEOUT_MS = 25000; @@ -921,6 +933,22 @@ export default { if (url.pathname === "/api/owner-decisions" && request.method === "POST") { return await recordOwnerDecisionResponse(request, env); } + if (url.pathname.startsWith("/api/decisions/")) { + const rest = url.pathname.slice("/api/decisions/".length); + if (rest.endsWith("/actions")) { + if (request.method !== "POST") return json({ ok: false, error: "method_not_allowed" }, 405); + return await decisionActionResponse(request, env, rest.slice(0, -"/actions".length)); + } + return await decisionReviewResponse(request, env, rest); + } + if (url.pathname.startsWith("/api/operations/")) { + if (request.method !== "GET") return json({ ok: false, error: "method_not_allowed" }, 405); + return await operationResponse(request, env, url.pathname.slice("/api/operations/".length)); + } + if (url.pathname === "/api/audit-events") { + if (request.method !== "GET") return json({ ok: false, error: "method_not_allowed" }, 405); + return await auditEventsResponse(request, env, url); + } if (url.pathname === "/api/internal/sync-reconciliation-recovery-source" && request.method === "POST") { return await syncReconciliationRecoverySourceResponse(request, env); } @@ -3670,6 +3698,10 @@ export class RuntimeInstances { if (binding.no_order !== true || binding.execution_authority_granted !== false) throw new HttpError("risk_profile_execution_authority_rejected", 400); if (binding.scope_id !== riskProfileScopeId(binding.platform, binding.target_name)) throw new HttpError("invalid_risk_profile_binding", 400); if (!RISK_PROFILE_PREFERENCES.includes(binding.profile_selection?.risk_preference)) throw new HttpError("invalid_risk_profile_binding", 400); + const policy = RISK_TIER_POLICY_BINDINGS[binding.profile_selection?.risk_preference]; + if (!policy || binding.profile_selection?.profile_id !== policy.profile_id) { + throw new HttpError("risk_profile_policy_binding_invalid", 409); + } if (!riskProfileBindingTargets(accountOptions).some((target) => target.platform === binding.platform && target.target_name === binding.target_name)) { throw new HttpError("risk_profile_binding_target_not_configured", 409); } @@ -4359,7 +4391,7 @@ export class RuntimeInstances { if (ACCOUNT_DIAGNOSIS_DO_ACTIONS.has(command?.action)) { return json(await this.accountDiagnosisCommand(command)); } - if (["console_decision_history", "console_settings_history", "console_account_metadata"].includes(command?.action)) { + if (["console_decision_history", "console_settings_history", "console_account_metadata", "console_audit_events"].includes(command?.action)) { return json(this.consoleReadCommand(command)); } if (HUMAN_DECISION_DO_ACTIONS.has(command?.action)) { @@ -4626,6 +4658,22 @@ export class RuntimeInstances { consoleReadCommand(command) { if (!command.actor) throw new HttpError("runtime_instance_actor_required", 400); + if (command.action === "console_audit_events") { + const limit = command.limit; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > AUDIT_EVENTS_MAX_ROWS) throw new HttpError("invalid_pagination", 400); + return { rows: this.sql.exec(` + SELECT 'decision' AS source, kind AS kind, subject_id AS subject_id, material_key AS revision, + action AS action, actor AS actor, decided_at AS occurred_at, request_key AS ref, 'decided' AS status + FROM human_decision_record + UNION ALL + SELECT 'promotion_application', 'promotion', ticket_id, '', status, '', updated_at, application_id, status + FROM research_promotion_applications + UNION ALL + SELECT 'account_settings_application', 'account_settings', platform || ':' || account_key, '', + status, approved_by, approved_at, request_key, status + FROM account_setting_profile_application + ORDER BY occurred_at DESC, ref DESC LIMIT ?`, limit).toArray() }; + } if (command.action === "console_decision_history") { const { limit, offset } = command; if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0 || offset > 100000) throw new HttpError("invalid_pagination", 400); @@ -4896,7 +4944,7 @@ export class RuntimeInstances { if (row.legacy_state === "blocked") throw new HttpError("human_decision_legacy_blocked", 409); if (row.material_key !== materialKey) throw new HttpError("human_decision_material_conflict", 409); if (Number(row.eligible) !== 1) throw new HttpError("human_decision_material_ineligible", 409); - if (row.expires_at_ms != null && Number(command.now_ms) > Number(row.expires_at_ms)) { + if (row.expires_at_ms != null && Number(command.now_ms) >= Number(row.expires_at_ms)) { throw new HttpError("human_decision_material_expired", 409); } if (kind === "promotion" && command.review_binding !== undefined @@ -4953,7 +5001,10 @@ export class RuntimeInstances { readHumanDecision(command, kind) { const subjectId = String(command.subject_id || ""); + const now = Number.isFinite(Number(command.now_ms)) ? Number(command.now_ms) : Date.now(); const row = this.humanDecisionMaterialRow(kind, subjectId); + const expiry = (material) => material.expires_at_ms == null ? null : new Date(Number(material.expires_at_ms)).toISOString(); + const expired = (material) => material.expires_at_ms != null && now >= Number(material.expires_at_ms); // Historical receipt lookup deliberately does not substitute the current material. // It grants no eligibility or authority to act on the current material. if (command.exact_material_key !== undefined) { @@ -4968,19 +5019,23 @@ export class RuntimeInstances { decision: terminal ? this.humanDecisionRecordPayload(terminal) : null, current_material: row ? { matches: row.material_key === materialKey, - eligible: Number(row.eligible) === 1 && (row.expires_at_ms == null || Number(command.now_ms) <= Number(row.expires_at_ms)), + eligible: Number(row.eligible) === 1 && !expired(row), + expired: expired(row), + expires_at: expiry(row), blocked: row.legacy_state === "blocked", ...(kind === "promotion" ? { review_binding: promotionMaterialReviewBinding(row) } : {}), } : null, }; } - if (!row) return { ok: true, migrated: false, blocked: false, eligible: false, material_key: null, decision: null }; + if (!row) return { ok: true, migrated: false, blocked: false, eligible: false, expired: false, expires_at: null, material_key: null, decision: null }; const terminal = this.humanDecisionTerminalRow(kind, subjectId, row.material_key); return { ok: true, migrated: true, blocked: row.legacy_state === "blocked", - eligible: Number(row.eligible) === 1, + eligible: Number(row.eligible) === 1 && !expired(row), + expired: expired(row), + expires_at: expiry(row), material_key: row.material_key, ...(kind === "promotion" ? { review_binding: promotionMaterialReviewBinding(row) } : {}), decision: terminal ? this.humanDecisionRecordPayload(terminal) : null, @@ -5425,6 +5480,13 @@ async function riskProfileBindingsResponse(request, env) { ok: true, revision: bindingState.revision, bindings: bindingState.bindings, + tier_bindings: bindingState.bindings.map((binding) => riskTierBindingView({ + scope_id: binding.scope_id, + platform: binding.platform, + target_name: binding.target_name, + binding, + revision: bindingState.revision, + })), configured_targets: riskProfileBindingTargets(bindingState.account_options || {}), no_order: true, execution_authority_granted: false, @@ -5595,6 +5657,13 @@ function accountSettingsPayload(observed, effective, admin, extras = {}) { scope_id: observed.risk.scope_id, preference: observed.risk.preference, }, + tier_binding: riskTierBindingView({ + scope_id: observed.risk.scope_id, + platform: observed.platform, + target_name: observed.identity?.target_name || null, + binding: observed.risk.binding || null, + revision: observed.risk.revision, + }), operations: accountSettingsOperations(admin, optionSupported), adopted: false, no_order: true, @@ -7650,10 +7719,35 @@ function humanDecisionTimestamp(value) { return String(value || new Date().toISOString()).replace(/\.\d{3}Z$/, "Z"); } +// Actionable material expires when the evidence that produced it would itself be +// stale. A missing or future-dated anchor yields null, which callers treat as +// "not actionable" rather than inventing authority. +function controlPlaneDecisionExpiryAt(snapshot, ttlSeconds, now = Date.now()) { + const timestamps = [snapshot?.generated_at, snapshot?.computed_at] + .filter(Boolean) + .map((value) => Date.parse(value)) + .filter((value) => Number.isFinite(value)); + if (!timestamps.length) return null; + const observedAt = Math.min(...timestamps); + if (observedAt > now + 5 * 60 * 1000) return null; + return observedAt + ttlSeconds * 1000; +} + +function reconciliationRecoveryDecisionExpiryAt(recovery, source, ttlSeconds, now = Date.now()) { + const sourceExpiry = controlPlaneDecisionExpiryAt(source, ttlSeconds, now); + if (sourceExpiry == null) return null; + const observedAt = Date.parse(recovery.mode === "initial_enrollment" + ? recovery.candidate?.last_observed_at + : recovery.last_observed_at); + if (!Number.isFinite(observedAt) || observedAt > now + 5 * 60 * 1000) return null; + return Math.min(sourceExpiry, observedAt + ttlSeconds * 1000); +} + async function publishHumanOwnerMaterials(env, snapshot, sourceId) { const ownerCandidates = (snapshot.candidates || []).filter(isOwnerDecisionCandidate); if (!ownerCandidates.length && !hasRuntimeInstanceStore(env)) return { ok: true, skipped: true }; if (!hasRuntimeInstanceStore(env)) throw new HttpError("runtime_instances_not_bound", 503); + const expiresAtMs = controlPlaneDecisionExpiryAt(snapshot, controlPlaneStaleTtlSeconds(env)); const grouped = new Map(); for (const candidate of ownerCandidates) { const list = grouped.get(candidate.candidate_id) || []; @@ -7695,7 +7789,7 @@ async function publishHumanOwnerMaterials(env, snapshot, sourceId) { material_key: materialKey, summary_json: JSON.stringify({ candidate_id: subjectId }), eligible: list.length === 1 && candidate.freshness?.status === "fresh", - expires_at_ms: null, + expires_at_ms: expiresAtMs, legacy, }); } @@ -7772,7 +7866,7 @@ async function publishHumanRecoveryMaterials(env, source) { material_key: materialKey, summary_json: JSON.stringify({ recovery_id: subjectId }), eligible: list.length === 1 && recoveryMaterialEligible(recovery, sourceFreshness, evidenceFreshness), - expires_at_ms: null, + expires_at_ms: reconciliationRecoveryDecisionExpiryAt(recovery, source, ttlSeconds, now), legacy, }); } @@ -7833,7 +7927,7 @@ async function publishHumanPromotionMaterial(env, ticket, existing) { review_binding: await researchPromotionReviewBinding(ticket), }), eligible: true, - expires_at_ms: null, + expires_at_ms: Date.now() + controlPlaneStaleTtlSeconds(env) * 1000, legacy, }], }); @@ -7948,6 +8042,24 @@ async function readHumanDecisionAuthority(env, kind, subjectId) { return runtimeInstanceCommand(env, { action: `${kind}_read`, actor: `${kind}-decision-read`, subject_id: subjectId }); } +// A safe projection of durable material authority. It carries only digests and +// server-side expiry; raw provider payloads and credentials never enter a read model. +function humanDecisionAuthorityView(authority) { + if (!authority || typeof authority !== "object") return null; + const materialKey = /^[0-9a-f]{64}$/.test(authority.material_key || "") ? authority.material_key : null; + const expiresAt = typeof authority.expires_at === "string" && Number.isFinite(Date.parse(authority.expires_at)) + ? authority.expires_at + : null; + return { + material_sha256: materialKey, + expires_at: expiresAt, + eligible: typeof authority.eligible === "boolean" ? authority.eligible : null, + blocked: authority.blocked === true, + unavailable: authority.migrated === false, + source: "durable_human_decision_material", + }; +} + function overlayPromotionDecision(ticket, authority) { if (!authority || authority.migrated === false) return ticket; const current = authority.eligible === true && authority.blocked !== true ? authority.decision : null; @@ -8219,24 +8331,25 @@ async function readOwnerDecisionIntent(env, candidateId, materialKey) { try { authority = await readHumanDecisionAuthority(env, "owner", candidateId); } catch (error) { - return { intent: null, error: error.message || "runtime_instances_unavailable" }; + return { intent: null, authority: null, error: error.message || "runtime_instances_unavailable" }; } - if (authority.blocked) return { intent: null, error: "owner_decision_legacy_blocked" }; + const authorityView = humanDecisionAuthorityView(authority); + if (authority.blocked) return { intent: null, authority: authorityView, error: "owner_decision_legacy_blocked" }; if (authority.decision && authority.material_key === materialKey && authority.decision.payload?.no_order === true && authority.decision.payload?.execution_authority_granted === false) { - return { intent: authority.decision.payload, error: null }; + return { intent: authority.decision.payload, authority: authorityView, error: null }; } if (authority.migrated === false) { try { const stored = await readConfigJson(env, ownerDecisionCurrentKey(candidateId)); - if (!stored) return { intent: null, error: null }; + if (!stored) return { intent: null, authority: authorityView, error: null }; const intent = await normalizeOwnerDecisionIntent(stored, ownerDecisionCurrentKey(candidateId)); - if (materialKey && intent.candidate_evidence_sha256 !== materialKey) return { intent: null, error: null }; - return { intent, error: null, read_only: true }; + if (materialKey && intent.candidate_evidence_sha256 !== materialKey) return { intent: null, authority: authorityView, error: null }; + return { intent, authority: authorityView, error: null, read_only: true }; } catch { - return { intent: null, error: "owner_decision_intent_invalid" }; + return { intent: null, authority: authorityView, error: "owner_decision_intent_invalid" }; } } - return { intent: null, error: null }; + return { intent: null, authority: authorityView, error: null }; } function ownerDecisionArchiveKey(candidateId, decisionSha256) { @@ -8775,37 +8888,40 @@ async function readReconciliationRecoveryConfirmation(env, recoveryId) { try { authority = await readHumanDecisionAuthority(env, "recovery", recoveryId); } catch (error) { - return { confirmation: null, error: error.message || "runtime_instances_unavailable" }; + return { confirmation: null, rejection: null, authority: null, error: error.message || "runtime_instances_unavailable" }; } + const authorityView = humanDecisionAuthorityView(authority); if (authority.migrated === false) { try { const stored = await readConfigJson(env, reconciliationRecoveryConfirmationCurrentKey(recoveryId)); - if (!stored) return { confirmation: null, error: null }; + if (!stored) return { confirmation: null, rejection: null, authority: authorityView, error: null }; return { confirmation: await normalizeReconciliationRecoveryConfirmation( stored, reconciliationRecoveryConfirmationCurrentKey(recoveryId), ), + rejection: null, + authority: authorityView, error: null, read_only: true, }; } catch { - return { confirmation: null, error: "reconciliation_recovery_confirmation_invalid" }; + return { confirmation: null, rejection: null, authority: authorityView, error: "reconciliation_recovery_confirmation_invalid" }; } } - if (authority.blocked) return { confirmation: null, rejection: null, error: "reconciliation_recovery_legacy_blocked" }; - if (authority.eligible !== true) return { confirmation: null, rejection: null, error: null }; + if (authority.blocked) return { confirmation: null, rejection: null, authority: authorityView, error: "reconciliation_recovery_legacy_blocked" }; + if (authority.eligible !== true) return { confirmation: null, rejection: null, authority: authorityView, error: null }; if (authority.decision?.action === "reject") { const payload = authority.decision.payload; if (payload?.decision === "reject" && payload?.no_order === true && payload?.execution_authority_granted === false) { - return { confirmation: null, rejection: payload, error: null }; + return { confirmation: null, rejection: payload, authority: authorityView, error: null }; } - return { confirmation: null, rejection: null, error: null }; + return { confirmation: null, rejection: null, authority: authorityView, error: null }; } if (authority.decision?.action === "approve" && authority.decision.payload?.no_order === true && authority.decision.payload?.execution_authority_granted === false) { - return { confirmation: authority.decision.payload, rejection: null, error: null }; + return { confirmation: authority.decision.payload, rejection: null, authority: authorityView, error: null }; } - return { confirmation: null, rejection: null, error: null }; + return { confirmation: null, rejection: null, authority: authorityView, error: null }; } async function recordInitialEnrollmentDecision(env, session, requested, recovery) { @@ -9002,6 +9118,7 @@ async function aggregateReconciliationRecoverySources(env) { presentation.label = { zh: "首次登记确认", en: "Initial enrollment" }; presentation.material_kind = "initial_enrollment"; recoveries.push({ source_id: source.source_id, freshness, recovery: view, confirmation, rejection, presentation, + decision_authority: result.authority || null, decision_binding: { kind: "recovery", subject_id: view.recovery_id, material_sha256: await recoveryDecisionMaterialKey(recovery) } }); continue; } @@ -9017,6 +9134,7 @@ async function aggregateReconciliationRecoverySources(env) { : null; recoveries.push({ source_id: source.source_id, freshness, recovery, confirmation, rejection, presentation: recoveryMaterialPresentation({ source, recovery, confirmation, rejection, decisionError: result.error }), + decision_authority: result.authority || null, decision_binding: { kind: "recovery", subject_id: recovery.recovery_id, material_sha256: await recoveryDecisionMaterialKey(recovery) }, }); } @@ -12575,6 +12693,19 @@ async function researchPromotionTicketsResponse(request, env) { } }); } const awaiting = tickets.filter((ticket) => ticket.state === "awaiting_human"); + // Current authority is read from the durable material, never inferred from the + // ticket body; an unreadable authority leaves the ticket visibly non-actionable. + if (hasRuntimeInstanceStore(env)) { + await Promise.all(awaiting.map(async (ticket) => { + try { + ticket.decision_authority = humanDecisionAuthorityView( + await readHumanDecisionAuthority(env, "promotion", ticket.ticket_id), + ); + } catch { + ticket.decision_authority = null; + } + })); + } const applicationTickets = tickets.filter((ticket) => ticket.state === "awaiting_human" || ticket.state === "human_accepted"); const accountConfig = await loadAccountOptionsConfig(env); @@ -12618,6 +12749,533 @@ async function researchPromotionTicketsResponse(request, env) { }); } +const DECISION_POLICY = Object.freeze({ + no_order: true, + execution_authority_granted: false, + current_evidence_required: true, + human_confirmation_required: true, + notice: "只记录人工决定意图;不会下单、变更资金或启用实盘。", +}); + +function decisionInboxSources() { + return { + owner: ownerDecisionQueueResponse, + promotion: researchPromotionTicketsResponse, + recovery: reconciliationRecoveryResponse, + credential: (request, env) => credentialMaintenanceResponse( + request, env, new URL("/api/credential-maintenance", request.url), + ), + }; +} + +function decodeDecisionApiId(raw) { + let id; + try { + id = decodeURIComponent(String(raw || "")); + } catch { + return null; + } + const parts = decisionCategoryPart(id); + if (parts) return { id, category: parts.category, subject_id: parts.subject_id }; + const legacyCredential = /^credential:(.+)$/.exec(id); + if (!legacyCredential || legacyCredential[1].length > 256 || /[\u0000-\u001f\u007f]/.test(legacyCredential[1])) return null; + return { id, category: "credential_maintenance", subject_id: legacyCredential[1] }; +} + +function decodeOperationApiId(raw) { + let id; + try { + id = decodeURIComponent(String(raw || "")); + } catch { + return null; + } + const match = /^operation:(owner_decision|strategy_promotion|paper_account_application|reconciliation_recovery|credential_maintenance):(.+)$/.exec(id); + if (!match || match[2].length > 256 || /[\u0000-\u001f\u007f]/.test(match[2])) return null; + return { id, category: match[1], subject_id: match[2] }; +} + +const DECISION_SOURCE_BY_CATEGORY = Object.freeze({ + owner_decision: "owner", + strategy_promotion: "promotion", + paper_account_application: "promotion", + reconciliation_recovery: "recovery", + credential_maintenance: "credential", +}); + +/** + * Absence is only proven when the owning source was read. A failed or + * unavailable source stays a 503 so a missing id never masks a broken read. + */ +function decisionSourceUnreadable(queue, category) { + const name = DECISION_SOURCE_BY_CATEGORY[category]; + const source = (queue?.sources || []).find((entry) => entry.source === name); + if (!source) return true; + return ["read_failed", "unavailable"].includes(source.status); +} + +async function loadDecisionFromInbox(request, env, id) { + const queue = await inbox(request, env, decisionInboxSources()); + return { queue, item: queue.items.find((item) => item.id === id) || null }; +} + +async function readDecisionAuthorityOrNull(env, kind, subjectId) { + if (!kind) return null; + if (!hasRuntimeInstanceStore(env)) throw new HttpError("runtime_instances_not_bound", 503); + return readHumanDecisionAuthority(env, kind, subjectId); +} + +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); + if (!session?.allowed) return json({ ok: false, error: "login required", error_state: "unauthorized" }, 401); + const parsed = decodeDecisionApiId(rawId); + if (!parsed) return json({ ok: false, error: "decision_id_invalid" }, 400); + const { queue, item } = await loadDecisionFromInbox(request, env, parsed.id); + if (!item) { + if (decisionSourceUnreadable(queue, parsed.category)) + return json({ ok: false, error: "decision_source_unavailable", error_state: queue.error_state || null }, 503); + return json({ ok: false, error: "decision_not_found" }, 404); + } + const kind = decisionKind(parsed.category); + let authorityRaw; + try { + authorityRaw = await readDecisionAuthorityOrNull(env, kind, parsed.subject_id); + } catch { + return json({ ok: false, error: "decision_authority_unavailable" }, 503); + } + const authority = authorityRaw ? humanDecisionAuthorityView(authorityRaw) : null; + const now = Date.now(); + let receipt = null; + const errors = []; + if (authorityRaw?.decision) { + try { + receipt = await humanDecisionReceipt(authorityRaw.decision, { + kind, + subject_id: parsed.subject_id, + material_sha256: authorityRaw.decision.material_key, + }); + } catch { + errors.push("decision_receipt_unavailable"); + } + } + if (authorityRaw?.migrated === false) errors.push("decision_authority_unavailable"); + if (queue.status !== "ok") errors.push("decision_source_partial"); + return json({ + schema_version: DECISION_REVIEW_SCHEMA_VERSION, + server_time: new Date(now).toISOString(), + data_status: errors.length ? "partial" : "ready", + decision_id: item.id, + decision: item, + current_eligibility: decisionCurrentEligibility(item, authority, now), + decision_authority: authority, + decision_receipt: receipt, + operation: item.operation_reference, + policy: DECISION_POLICY, + errors: [...new Set(errors)], + }); +} + +function decisionSelection(raw) { + const selection = raw?.selection; + if (!selection || Array.isArray(selection) || typeof selection !== "object") return null; + return { + confirmation: selection.confirmation && typeof selection.confirmation === "object" && !Array.isArray(selection.confirmation) + ? selection.confirmation : null, + selected_account: selection.selected_account && typeof selection.selected_account === "object" && !Array.isArray(selection.selected_account) + ? selection.selected_account : null, + }; +} + +async function decisionActionResponse(request, env, rawId) { + requireSameOrigin(request, { requireOrigin: true }); + const session = await readSession(request, env); + if (!session?.allowed) return json({ ok: false, error: "login required", error_state: "unauthorized" }, 401); + if (!session.admin) return json({ ok: false, error: "admin required" }, 403); + const parsed = decodeDecisionApiId(rawId); + if (!parsed) return json({ ok: false, error: "decision_id_invalid" }, 400); + const supported = DECISION_ACTIONS[parsed.category] || []; + const kind = decisionKind(parsed.category); + if (!kind || !supported.length) return json({ ok: false, error: "decision_not_actionable" }, 409); + + let raw; + try { + raw = await readBoundedJson(request, DECISION_ACTION_MAX_BODY_BYTES); + } catch (error) { + return json({ ok: false, error: error.message || "invalid decision action" }, error.status || 400); + } + if (!raw || Array.isArray(raw) || typeof raw !== "object") + return json({ ok: false, error: "decision_action_request_invalid" }, 400); + if (Object.keys(raw).some((key) => !["action", "expected_revision", "expected_expires_at", "request_id", "selection"].includes(key))) + return json({ ok: false, error: "decision_action_request_invalid" }, 400); + const action = typeof raw.action === "string" ? raw.action.trim() : ""; + if (!supported.includes(action)) + return json({ ok: false, error: "decision_action_invalid" }, 400); + if (typeof raw.expected_revision !== "string" || !/^rev[0-9]-[0-9a-z-]{1,64}$/.test(raw.expected_revision)) + return json({ ok: false, error: "decision_revision_required" }, 400); + const requestId = typeof raw.request_id === "string" ? raw.request_id.trim() : ""; + if (!/^[A-Za-z0-9._:-]{1,128}$/.test(requestId)) + return json({ ok: false, error: "invalid request_id" }, 400); + const selection = decisionSelection(raw); + if ((parsed.category === "strategy_promotion" || parsed.category === "paper_account_application") + && action === "accept" && (!selection?.confirmation || !selection?.selected_account)) + return json({ ok: false, error: "decision_selection_required" }, 400); + + const { queue, item } = await loadDecisionFromInbox(request, env, parsed.id); + if (!item) { + if (decisionSourceUnreadable(queue, parsed.category)) + return json({ ok: false, error: "decision_source_unavailable", error_state: queue.error_state || null }, 503); + return json({ ok: false, error: "decision_not_found" }, 404); + } + const now = Date.now(); + const serverTime = new Date(now).toISOString(); + let authorityRaw; + try { + authorityRaw = await readHumanDecisionAuthority(env, kind, parsed.subject_id); + } catch { + return json({ ok: false, error: "decision_authority_unavailable", server_time: serverTime }, 503); + } + const authority = humanDecisionAuthorityView(authorityRaw); + const materialKey = item.decision_binding?.material_sha256 || null; + const existing = authorityRaw?.decision; + if (existing && materialKey && existing.material_key === materialKey) { + if (existing.kind !== kind || existing.subject_id !== parsed.subject_id || existing.action !== action) + return json({ ok: false, error: "human_decision_conflict", decision_id: item.id, server_time: serverTime }, 409); + const receipt = await humanDecisionReceipt(existing, { + kind, subject_id: parsed.subject_id, material_sha256: materialKey, + }); + return json({ + schema_version: DECISION_ACTION_SCHEMA_VERSION, + ok: true, + replayed: true, + decision_id: item.id, + revision: item.revision, + action, + decision_receipt: receipt, + decided_at: existing.decided_at, + decision_state: { status: "decided", action, decided_at: existing.decided_at }, + operation: item.operation_reference, + policy: DECISION_POLICY, + server_time: serverTime, + }); + } + if (raw.expected_revision !== item.revision) + return json({ ok: false, error: "decision_revision_conflict", decision_id: item.id, server_time: serverTime }, 409); + if (raw.expected_expires_at !== undefined && raw.expected_expires_at !== item.expires_at) + return json({ ok: false, error: "decision_expiry_changed", decision_id: item.id, server_time: serverTime }, 409); + if (decisionExpiryState(item.expires_at, now) !== "active") + return json({ ok: false, error: "decision_expired", decision_id: item.id, server_time: serverTime }, 409); + if (decisionExpiryState(authority?.expires_at, now) !== "active") + return json({ ok: false, error: "decision_expired", decision_id: item.id, server_time: serverTime }, 409); + if (authority?.blocked === true) + return json({ ok: false, error: "decision_legacy_blocked", decision_id: item.id, server_time: serverTime }, 409); + if (authority?.eligible === false) + return json({ ok: false, error: "decision_material_ineligible", decision_id: item.id, server_time: serverTime }, 409); + if (item.actionable !== true) + return json({ + ok: false, error: "decision_not_actionable", decision_id: item.id, + blocking_reason_codes: item.blocking_reason_codes || [], server_time: serverTime, + }, 409); + if (!(item.allowed_actions || []).some((entry) => entry.action === action)) + return json({ ok: false, error: "decision_action_not_allowed", decision_id: item.id, server_time: serverTime }, 409); + if (!materialKey) + return json({ ok: false, error: "decision_evidence_unavailable", decision_id: item.id, server_time: serverTime }, 409); + + let targetPath; + let innerBody; + if (parsed.category === "owner_decision") { + targetPath = "/api/owner-decisions"; + innerBody = { + candidate_id: parsed.subject_id, + decision: action, + candidate_evidence_sha256: materialKey, + request_id: requestId, + }; + } else if (parsed.category === "reconciliation_recovery") { + targetPath = "/api/reconciliation-recovery-confirmations"; + if (item.initial_enrollment) { + innerBody = { + mode: "initial_enrollment", + recovery_id: parsed.subject_id, + candidate_sha256: item.initial_enrollment.candidate_sha256, + facts_sha256: item.initial_enrollment.facts_sha256, + decision: action, + request_id: requestId, + }; + } else { + innerBody = { + recovery_id: parsed.subject_id, + candidate_sha256: item.evidence_summary?.candidate_sha256, + dual_review_binding_sha256: item.evidence_summary?.dual_review_binding_sha256, + decision: action, + request_id: requestId, + }; + } + } else { + targetPath = "/api/research-promotion-decisions"; + let ticket; + try { + ticket = await loadStoredResearchPromotionTicket(env, parsed.subject_id); + } catch (error) { + return json({ + ok: false, error: error.message || "research promotion ticket unavailable", + decision_id: item.id, server_time: serverTime, + }, error.status || 503); + } + innerBody = { + ticket_id: parsed.subject_id, + decision: action, + expected_proposed_params: ticket.proposed_params, + expected_material_sha256: materialKey, + ...(item.decision_binding?.review_binding?.review_sha256 + ? { expected_review_sha256: item.decision_binding.review_binding.review_sha256 } : {}), + ...(action === "accept" && selection ? { + confirmation: selection.confirmation, + selected_account: selection.selected_account, + } : {}), + request_id: requestId, + }; + } + + const innerResponse = await ({ + "/api/owner-decisions": recordOwnerDecisionResponse, + "/api/reconciliation-recovery-confirmations": recordReconciliationRecoveryConfirmationResponse, + "/api/research-promotion-decisions": recordResearchPromotionDecisionResponse, + })[targetPath](new Request(new URL(targetPath, request.url), { + method: "POST", + headers: new Headers(request.headers), + body: JSON.stringify(innerBody), + }), env); + let payload = null; + try { + payload = await innerResponse.json(); + } catch { + payload = null; + } + if (!innerResponse.ok || payload?.ok !== true) { + return json({ + ...(payload && typeof payload === "object" ? payload : { ok: false, error: "decision_action_unavailable" }), + schema_version: DECISION_ACTION_SCHEMA_VERSION, + decision_id: item.id, + action, + server_time: serverTime, + }, innerResponse.status || 503); + } + const decidedAt = payload.intent?.decided_at + || payload.confirmation?.confirmed_at + || payload.ticket?.human_decided_at + || null; + return json({ + schema_version: DECISION_ACTION_SCHEMA_VERSION, + ok: true, + replayed: payload.replayed === true, + decision_id: item.id, + revision: item.revision, + action, + decision_receipt: payload.decision_receipt || null, + decided_at: decidedAt, + decision_state: { status: "decided", action, decided_at: decidedAt }, + operation: item.operation_reference, + policy: DECISION_POLICY, + server_time: serverTime, + }); +} + +function operationDecisionStatus(category, decision, application) { + const promoted = category === "strategy_promotion" || category === "paper_account_application"; + if (promoted && application) { + return { status: operationStatusFromApplication(application), reason_code: application.reason_code || null }; + } + if (!decision) return { status: "unaccepted", reason_code: "decision_pending" }; + // The operation records the human decision; it never implies activation. + if (promoted) return decision.action === "accept" + ? { status: "accepted", reason_code: "application_not_created" } + : { status: "failed", reason_code: "decision_rejected" }; + return { status: "accepted", reason_code: "operation_not_connected" }; +} + +async function operationResponse(request, env, rawId) { + const session = await readSession(request, env); + if (!session?.allowed) return json({ ok: false, error: "login required", error_state: "unauthorized" }, 401); + const parsed = decodeOperationApiId(rawId); + if (!parsed) return json({ ok: false, error: "operation_not_found" }, 404); + const now = Date.now(); + const serverTime = new Date(now).toISOString(); + const kind = decisionKind(parsed.category); + if (kind && !hasRuntimeInstanceStore(env)) + return json({ ok: false, error: "runtime_instances_not_bound", server_time: serverTime }, 503); + + let authorityRaw = null; + let ticket = null; + if (kind) { + try { + if (parsed.category === "strategy_promotion" || parsed.category === "paper_account_application") { + ticket = await loadStoredResearchPromotionTicket(env, parsed.subject_id); + } + authorityRaw = await readHumanDecisionAuthority(env, kind, parsed.subject_id); + } catch (error) { + return json({ + ok: false, error: error.message || "operation_unavailable", + operation_id: parsed.id, server_time: serverTime, + }, error.status || 503); + } + } + let application = null; + if (kind === "promotion") { + try { + const stored = await runtimeInstanceCommand(env, { + action: "application_latest", actor: session.login, ticket_id: parsed.subject_id, + }); + application = stored.application || null; + } catch { + return json({ ok: false, error: "operation_unavailable", operation_id: parsed.id, server_time: serverTime }, 503); + } + } + const decision = authorityRaw?.decision || null; + const status = operationDecisionStatus(parsed.category, decision, application); + const publicApplication = application ? publicResearchPromotionApplication(application) : null; + return json({ + schema_version: OPERATION_SCHEMA_VERSION, + server_time: serverTime, + data_status: "ready", + operation: { + id: parsed.id, + decision_id: `${parsed.category}:${parsed.subject_id}`, + kind: kind || parsed.category, + subject_id: parsed.subject_id, + status: status.status, + reason_code: status.reason_code, + activation_status: "not_connected", + updated_at: publicApplication?.updated_at || decision?.decided_at || ticket?.updated_at || null, + dispatch: publicApplication ? { + status: publicApplication.dispatch_state || "unknown", + workflow_run_id: publicApplication.workflow_run_id || null, + updated_at: publicApplication.updated_at || null, + } : null, + application: publicApplication, + }, + decision: decision ? { + material_sha256: /^[0-9a-f]{64}$/.test(decision.material_key || "") ? decision.material_key : null, + action: decision.action, + decided_at: decision.decided_at, + } : null, + policy: DECISION_POLICY, + errors: [], + }); +} + +function auditEventSubjectId(row) { + if (typeof row.ticket_id === "string" && row.ticket_id) return { kind: "strategy_promotion", id: row.ticket_id }; + if (typeof row.recovery_id === "string" && row.recovery_id) return { kind: "reconciliation_recovery", id: row.recovery_id }; + if (typeof row.candidate_id === "string" && row.candidate_id) return { kind: "owner_decision", id: row.candidate_id }; + if (typeof row.platform === "string" && typeof row.key === "string") return { kind: "account_settings", id: `${row.platform}:${row.key}` }; + return null; +} + +async function auditEventsResponse(request, env, url) { + const session = await readSession(request, env); + if (!session?.allowed) return json({ ok: false, error: "login required", error_state: "unauthorized" }, 401); + let page; + try { + page = pageQuery(url); + } catch { + return json({ ok: false, error: "invalid_pagination" }, 400); + } + const now = Date.now(); + const serverTime = new Date(now).toISOString(); + if (!hasRuntimeInstanceStore(env)) + return json({ ok: false, error: "runtime_instances_not_bound", server_time: serverTime }, 503); + let rows; + try { + const result = await runtimeInstanceCommand(env, { + action: "console_audit_events", actor: session.login, limit: AUDIT_EVENTS_MAX_ROWS, + }); + rows = Array.isArray(result.rows) ? result.rows : []; + } catch { + return json({ ok: false, error: "audit_projection_unavailable", server_time: serverTime }, 503); + } + const events = []; + for (const row of rows) { + const occurredAt = typeof row.occurred_at === "string" && Number.isFinite(Date.parse(row.occurred_at)) + ? row.occurred_at : null; + if (!occurredAt) continue; + const source = row.source === "decision" ? "durable_human_decision" + : row.source === "promotion_application" ? "research_promotion_application" + : row.source === "account_settings_application" ? "account_settings_application" + : null; + if (!source) continue; + const subject = source === "durable_human_decision" + ? { kind: `${row.kind}_decision`, id: row.subject_id } + : source === "research_promotion_application" + ? { kind: "strategy_promotion", id: row.subject_id } + : { kind: "account_settings", id: row.subject_id }; + if (!subject.id || subject.id.length > 256) continue; + const revision = /^[0-9a-f]{64}$/.test(row.revision || "") ? row.revision : null; + events.push({ + id: `${source}:${row.ref}`, + source, + occurred_at: occurredAt, + action: source === "durable_human_decision" ? `record_${row.kind}_decision` + : source === "promotion_application" ? "research_promotion_application" : "account_settings_approval", + actor_alias: await actorAlias(row.actor || null), + subject, + revision, + outcome: typeof row.status === "string" && row.status.length <= 40 ? row.status : null, + ref: source === "durable_human_decision" + ? `/api/decisions/${encodeURIComponent(`${row.kind === "promotion" ? "strategy_promotion" : row.kind === "owner" ? "owner_decision" : "reconciliation_recovery"}:${row.subject_id}`)}` + : source === "research_promotion_application" + ? `/api/operations/${encodeURIComponent(`operation:strategy_promotion:${row.subject_id}`)}` + : null, + }); + } + let dataStatus = "ready"; + const errors = []; + try { + const log = await loadAuditLog(env); + const aliases = await Promise.all(log.map((row) => + actorAlias(typeof row?.login === "string" ? row.login : null))); + log.forEach((row, index) => { + if (!row || typeof row !== "object") return; + const occurredAt = typeof row.ts === "string" && Number.isFinite(Date.parse(row.ts)) ? row.ts : null; + const action = typeof row.action === "string" && row.action.length <= 64 ? row.action : null; + if (!occurredAt || !action) return; + const subject = auditEventSubjectId(row); + events.push({ + id: `bounded_audit_log:${occurredAt}:${action}:${subject?.id || index}`, + source: "bounded_audit_log", + occurred_at: occurredAt, + action, + actor_alias: aliases[index] || null, + subject, + revision: null, + outcome: "recorded", + ref: null, + }); + }); + } catch { + dataStatus = "partial"; + errors.push("audit_log_unavailable"); + } + const seen = new Set(); + const unique = events + .filter((event) => { + if (seen.has(event.id)) return false; + seen.add(event.id); + return true; + }) + .sort((left, right) => right.occurred_at.localeCompare(left.occurred_at) || left.id.localeCompare(right.id)); + const pageEvents = unique.slice(page.offset, page.offset + page.limit); + return json({ + schema_version: AUDIT_EVENTS_SCHEMA_VERSION, + server_time: serverTime, + data_status: dataStatus, + events: pageEvents, + next_cursor: unique.length > page.offset + page.limit ? String(page.offset + page.limit) : null, + coverage: "durable_human_decisions_and_application_records_and_bounded_audit_log", + policy: DECISION_POLICY, + errors, + }); +} + function attachResearchPromotionApplicationPreparation(ticket, accountOptions, strategyProfiles) { const blockers = new Set(["activation_not_connected"]); if (ticket?.shadow_passed !== true || !String(ticket?.shadow_evidence_kind || "").trim()) { From 393896ea9b8496bf5b211db94f1d9ef9ad13f6e9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:44:27 +0800 Subject: [PATCH 04/13] test(console): cover decision cockpit API and inbox timestamps Adds an end-to-end validation for the decision review, action, operation and audit-event routes, and compares inbox payloads without their per-request server_time. --- tests/console_api_worker_validation.mjs | 20 +- tests/decision_cockpit_api_validation.mjs | 244 ++++++++++++++++++++++ 2 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 tests/decision_cockpit_api_validation.mjs diff --git a/tests/console_api_worker_validation.mjs b/tests/console_api_worker_validation.mjs index 7531d172..c2ef9ddc 100644 --- a/tests/console_api_worker_validation.mjs +++ b/tests/console_api_worker_validation.mjs @@ -11,6 +11,8 @@ const require = createRequire( new URL("../web/strategy-switch-console/package.json", import.meta.url), ); const { Miniflare } = require("miniflare"); +// Decision inbox payloads carry a per-request server_time; compare them without it. +const withoutServerTime = ({ server_time: _serverTime, ...rest }) => rest; const account = { key: "synthetic-alpha", label: "Synthetic alpha", @@ -239,7 +241,11 @@ try { assert.deepEqual(combined.body.accounts, accounts.body, "combined accounts retain the existing body"); validate("accounts", combined.body.accounts); validate("inbox", combined.body.inbox); - assert.deepEqual(combined.body.inbox, (await call("/api/decisions-inbox", { cookie: reader })).body); + assert.deepEqual( + withoutServerTime(combined.body.inbox), + withoutServerTime((await call("/api/decisions-inbox", { cookie: reader })).body), + "combined inbox matches the standalone inbox body apart from server_time", + ); assert.equal((await call("/api/accounts?include=decisions-inbox", { cookie: null })).status, 401); assert.equal((await call("/api/accounts?include=decisions-inbox", { method: "POST", body: {} })).status, 405); for (const query of ["include=", "include=other", "include=decisions-inbox&include=decisions-inbox", @@ -383,7 +389,10 @@ try { resetCounts(); const result = await read("/api/accounts?include=decisions-inbox"); assert.equal(result.status, 200); - assert.deepEqual(result.body, { accounts: oldAccounts.body, inbox: oldInbox.body }); + assert.deepEqual( + { accounts: result.body.accounts, inbox: withoutServerTime(result.body.inbox) }, + { accounts: oldAccounts.body, inbox: withoutServerTime(oldInbox.body) }, + ); assert.deepEqual(counts, { metadata: 1, owner: 1, promotion: 1, recovery: 1, credential: 1, outbound: 0 }); generation = 7; assert.equal((await read("/api/accounts?include=decisions-inbox")).body.accounts.accounts[0].pending_draft_count, 7, @@ -422,8 +431,11 @@ try { credentialForbidden = true; const forbiddenSource = await read("/api/accounts?include=decisions-inbox"); assert.equal(forbiddenSource.body.inbox.status, "read_failed"); - assert.deepEqual(forbiddenSource.body.inbox, (await read("/api/decisions-inbox")).body, - "a nested source 403 retains the original inbox classification"); + assert.deepEqual( + withoutServerTime(forbiddenSource.body.inbox), + withoutServerTime((await read("/api/decisions-inbox")).body), + "a nested source 403 retains the original inbox classification", + ); credentialForbidden = false; const beforeDenied = { ...counts }; assert.equal((await read("/api/accounts?include=decisions-inbox", { diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs new file mode 100644 index 00000000..0538c9be --- /dev/null +++ b/tests/decision_cockpit_api_validation.mjs @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { __test } from "./helpers/directory_worker.mjs"; + +const require = createRequire(new URL("../web/strategy-switch-console/package.json", import.meta.url)); +const { Miniflare } = require(process.env.QRT_MINIFLARE_MODULE || "miniflare"); +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const profiles = JSON.parse(readFileSync(resolve(root, "web/strategy-switch-console/strategy-profiles.example.json"), "utf8")); +const paperAccount = { + key: "paper", label: "LongBridge paper", target_name: "paper", runtime_status_target_id: "longbridge.paper", + account_selector: "PAPER", deployment_selector: "paper", account_scope: "PAPER", service_name: "longbridge-quant-paper-service", + supported_domains: ["us_equity"], broker_environment: "paper", default_execution_mode: "live", + default_strategy_profile: "russell_top50_leader_rotation", +}; +const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +const bindings = { + SESSION_SECRET: "decision-cockpit-fixture-session", + ALLOWED_GITHUB_LOGINS: "cockpit-admin,cockpit-reader", + STRATEGY_SWITCH_ADMIN_LOGINS: "cockpit-admin", + CONTROL_PLANE_SYNC_TOKEN: "cockpit-control-token", + RECONCILIATION_RECOVERY_SYNC_TOKEN: "cockpit-recovery-sync", + RESEARCH_PROMOTION_SYNC_TOKEN: "cockpit-promotion-sync", + STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON: JSON.stringify({ longbridge: [paperAccount] }), + STRATEGY_SWITCH_STRATEGY_PROFILES_JSON: JSON.stringify(profiles), +}; +const adminCookie = `qsl_switch_session=${await __test.makeSession("cockpit-admin", [], bindings)}`; +const options = { + cf: false, + modules: true, + modulesRules: [{ type: "ESModule", include: ["**/*.js"] }], + scriptPath: fileURLToPath(new URL("../web/strategy-switch-console/worker.js", import.meta.url)), + compatibilityDate: "2026-06-08", + bindings, + durableObjects: { STRATEGY_SWITCH_RUNTIME_INSTANCES: { className: "RuntimeInstances", useSQLite: true } }, + durableObjectsPersist: await mkdtemp(join(tmpdir(), "qrt-cockpit-do-")), + kvPersist: await mkdtemp(join(tmpdir(), "qrt-cockpit-kv-")), + kvNamespaces: ["STRATEGY_SWITCH_CONFIG"], + outboundService: () => new Response("offline only", { status: 503 }), +}; +const 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", { + method: "POST", body: JSON.stringify({ action: "maintain" }), + }); + assert.equal(response.status, 200); +} +await initializeDirectory(mf); +async function call(endpoint, { method = "GET", body, cookie = adminCookie, origin = "https://switch.example", headers = {} } = {}) { + const response = await mf.dispatchFetch(`https://switch.example${endpoint}`, { + method, + headers: { + ...(cookie ? { Cookie: cookie } : {}), + ...(origin ? { Origin: origin } : {}), + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + ...headers, + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + let payload = null; + const text = await response.text(); + try { payload = text ? JSON.parse(text) : null; } catch { payload = { raw: text }; } + return { status: response.status, body: payload, text, headers: response.headers }; +} +function ownerSnapshot(candidateId, generatedAt = now) { + return { + schema_version: "qsl_control_plane_dashboard.v1", + generated_at: generatedAt, computed_at: generatedAt, data_status: "ready", + summary: { candidate_count: 1, deferred: 0, parked: 0, owner_decision_required: 1 }, + candidates: [{ + candidate_id: candidateId, candidate_kind: "individual", domain: "us_equity", + lifecycle: { stage: "P6", status: "owner_decision_required" }, + evidence: { p1_input_digest: "1".repeat(64), p2_config_digest: "2".repeat(64), p3_evidence_id: "3".repeat(64), source_revision: "4".repeat(40) }, + recommendation: { code: "owner_live_decision", reason: "owner decision required" }, + freshness: { status: "fresh", age_seconds: 0 }, + }], + policy: { p4_p5_automation: "not_configured", p6_owner_decision_required: true, notice: "live remains owner-decided" }, + errors: [], + }; +} +function recoverySource(recoveryId, generatedAt) { + return { + schema_version: "qsl_reconciliation_recovery_source_snapshot.v1", + source_id: "fixture.recovery", generated_at: generatedAt, computed_at: generatedAt, data_status: "ready", + recoveries: [{ + recovery_id: recoveryId, platform: "ibkr", strategy_profile: "soxl_soxx_trend_income", environment: "live", + reconciliation_state: "RECONCILE_ONLY", readiness: "awaiting_human_confirmation", + candidate_sha256: "a".repeat(64), evidence_sample_count: 1, + first_observed_at: generatedAt, last_observed_at: generatedAt, + dual_review: { outcome: "unavailable", reviewer_count: 0, evidence_binding_sha256: "a".repeat(64) }, + blocker_codes: [], + }], + errors: [], + }; +} +const syncControl = (body) => call("/api/internal/sync-control-plane", { + method: "POST", cookie: "", origin: "", + headers: { Authorization: "Bearer cockpit-control-token" }, body, +}); +const syncRecovery = (body) => call("/api/internal/sync-reconciliation-recovery-source", { + method: "POST", cookie: "", origin: "", + headers: { Authorization: "Bearer cockpit-recovery-sync" }, body, +}); +const syncPromotion = (body) => call("/api/internal/sync-research-promotion-ticket", { + method: "POST", cookie: "", origin: "", + headers: { Authorization: "Bearer cockpit-promotion-sync" }, body, +}); + +// Inbox contract: actionable owner decisions carry revision, expiry, actions, evidence and operation. +const owner = ownerSnapshot("cockpit-owner"); +assert.equal((await syncControl(owner)).status, 200); +let inbox = await call("/api/decisions-inbox"); +assert.equal(inbox.status, 200); +assert.equal(typeof inbox.body.server_time, "string"); +const ownerItem = inbox.body.items.find((item) => item.decision_id === "owner_decision:cockpit-owner"); +assert.ok(ownerItem, "owner decision item is present"); +assert.match(ownerItem.revision, /^rev1-[0-9a-f]{16}$/); +assert.equal(ownerItem.actionable, true); +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); +assert.match(ownerItem.evidence_summary.material_sha256, /^[0-9a-f]{64}$/); +assert.equal(ownerItem.operation_reference.id, "operation:owner_decision:cockpit-owner"); +assert.equal(ownerItem.transition.pending_to, "small_live_intent"); +assert.equal(ownerItem.decision_authority.expires_at, ownerItem.expires_at); + +// GET /api/decisions/:id returns one authoritative review with server time and eligibility. +const review = await call(`/api/decisions/${encodeURIComponent(ownerItem.decision_id)}`); +assert.equal(review.status, 200); +assert.equal(review.body.schema_version, "qsl.decision_review.v1"); +assert.equal(typeof review.body.server_time, "string"); +assert.equal(review.body.decision_id, ownerItem.decision_id); +assert.equal(review.body.current_eligibility.eligible, true); +assert.equal(review.body.current_eligibility.expired, false); +assert.equal(review.body.decision.decision_id, ownerItem.decision_id); +assert.equal((await call("/api/decisions/owner_decision:missing")).status, 404); +assert.equal((await call("/api/decisions/not-a-decision")).status, 400); + +// POST /api/decisions/:id/actions delegates to the durable owner command. +const actionUrl = `/api/decisions/${encodeURIComponent(ownerItem.decision_id)}/actions`; +const wrongRevision = await call(actionUrl, { method: "POST", body: { + action: "keep_parked", expected_revision: "rev1-0000000000000000", request_id: "cockpit-request-1", +} }); +assert.equal(wrongRevision.status, 409); +assert.equal(wrongRevision.body.error, "decision_revision_conflict"); +const missingSelectionAction = await call(actionUrl, { method: "POST", body: { + action: "not_an_action", expected_revision: ownerItem.revision, request_id: "cockpit-request-1", +} }); +assert.equal(missingSelectionAction.status, 400); +assert.equal(missingSelectionAction.body.error, "decision_action_invalid"); +const decided = await call(actionUrl, { method: "POST", body: { + action: "keep_parked", expected_revision: ownerItem.revision, + expected_expires_at: ownerItem.expires_at, request_id: "cockpit-request-1", +} }); +assert.equal(decided.status, 200); +assert.equal(decided.body.ok, true); +assert.equal(decided.body.decision_id, ownerItem.decision_id); +assert.equal(decided.body.decision_state.status, "decided"); +assert.match(decided.body.decision_receipt.receipt_sha256, /^[0-9a-f]{64}$/); +assert.equal(decided.body.policy.no_order, true); +const replayed = await call(actionUrl, { method: "POST", body: { + action: "keep_parked", expected_revision: ownerItem.revision, request_id: "cockpit-request-1", +} }); +assert.equal(replayed.status, 200); +assert.equal(replayed.body.replayed, true); +assert.equal(replayed.body.decision_receipt.receipt_sha256, decided.body.decision_receipt.receipt_sha256); +const conflictingAction = await call(actionUrl, { method: "POST", body: { + action: "approve_limited_live_canary", expected_revision: ownerItem.revision, request_id: "cockpit-request-2", +} }); +assert.equal(conflictingAction.status, 409); +assert.equal(conflictingAction.body.error, "human_decision_conflict"); +const decidedReview = await call(`/api/decisions/${encodeURIComponent(ownerItem.decision_id)}`); +assert.equal(decidedReview.body.current_eligibility.eligible, false); +assert.equal(decidedReview.body.decision.allowed_actions.length, 0); +assert.equal(decidedReview.body.decision.decision_state.status, "decided"); + +// Operations resolve a recorded decision without resubmitting it. +const operation = await call(`/api/operations/${encodeURIComponent("operation:owner_decision:cockpit-owner")}`); +assert.equal(operation.status, 200); +assert.equal(operation.body.schema_version, "qsl.operation.v1"); +assert.equal(operation.body.operation.status, "accepted"); +assert.equal(operation.body.operation.activation_status, "not_connected"); +assert.equal(operation.body.decision.action, "keep_parked"); +assert.equal((await call("/api/operations/operation:not_a_kind:x")).status, 404); + +// Audit projection correlates the durable decision and the bounded log. +const audit = await call("/api/audit-events"); +assert.equal(audit.status, 200); +assert.equal(audit.body.schema_version, "qsl.audit_events.v1"); +assert.ok(audit.body.events.some((event) => event.source === "durable_human_decision" + && event.subject.id === "cockpit-owner" && event.outcome === "decided")); +assert.ok(audit.body.events.every((event) => !("login" in event) && "actor_alias" in event)); +const auditPage = await call("/api/audit-events?limit=1"); +assert.equal(auditPage.body.events.length, 1); +assert.equal(auditPage.body.next_cursor, "1"); +assert.equal((await call("/api/audit-events?cursor=bad")).status, 400); + +// Expired material is visible but never actionable, and the action route fails closed. +const expiredAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"); +assert.equal((await syncRecovery(recoverySource("cockpit-expired-recovery", expiredAt))).status, 200); +inbox = await call("/api/decisions-inbox"); +const expiredItem = inbox.body.items.find((item) => item.id === "reconciliation_recovery:cockpit-expired-recovery"); +assert.ok(expiredItem); +assert.equal(expiredItem.actionable, false); +assert.ok(expiredItem.blocking_reason_codes.includes("decision_expired")); +assert.equal(expiredItem.allowed_actions.length, 0); +const expiredReview = await call(`/api/decisions/${encodeURIComponent(expiredItem.decision_id)}`); +assert.equal(expiredReview.body.current_eligibility.expired, true); +assert.equal(expiredReview.body.current_eligibility.eligible, false); +const expiredAction = await call(`/api/decisions/${encodeURIComponent(expiredItem.decision_id)}/actions`, { method: "POST", body: { + action: "approve", expected_revision: expiredItem.revision, + expected_expires_at: expiredItem.expires_at, request_id: "cockpit-request-expired", +} }); +assert.equal(expiredAction.status, 409); +assert.equal(expiredAction.body.error, "decision_expired"); + +// Risk profile reads expose selected/approved/effective tier bindings. +const risk = await call("/api/risk-profiles"); +assert.equal(risk.status, 200); +assert.equal(risk.body.no_order, true); +assert.equal(risk.body.execution_authority_granted, false); +assert.ok(Array.isArray(risk.body.tier_bindings)); + +// Unauthenticated access fails closed. +assert.equal((await call("/api/decisions/owner_decision:cockpit-owner", { cookie: "" })).status, 401); +assert.equal((await call(actionUrl, { method: "POST", cookie: "", body: { + action: "keep_parked", expected_revision: ownerItem.revision, request_id: "cockpit-request-3", +} })).status, 401); +assert.equal((await call("/api/operations/operation:owner_decision:cockpit-owner", { cookie: "" })).status, 401); +assert.equal((await call("/api/audit-events", { cookie: "" })).status, 401); + +// A non-admin cannot record a decision through the adapter. +const reader = `qsl_switch_session=${await __test.makeSession("cockpit-reader", [], bindings)}`; +assert.equal((await call(actionUrl, { method: "POST", cookie: reader, body: { + action: "keep_parked", expected_revision: ownerItem.revision, request_id: "cockpit-request-4", +} })).status, 403); + +await mf.dispose(); +console.log("decision_cockpit_api_validation: PASS"); From 84eb7ba5b8c77b9db9058d3114b98a6d91841184 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:54:45 +0800 Subject: [PATCH 05/13] feat(console): project observed effective tier separately from approved Effective tier in the risk-tier binding projection was hard-coded to null. It now comes from an explicit runtime readback observation and stays null with reason code effective_tier_not_observed when no observation exists. The tier is never derived from the approved policy: ARCHITECTURE.md 4.4 requires selected, approved and effective policy to be displayed separately, and section 3.2 states runtime readback independently establishes effective state. No observed-tier source is wired into the console yet, so production reads still report null plus the reason code, and effective_derived_from_approved is a false constant in the contract. --- tests/decision_cockpit_api_validation.mjs | 22 +++++++++++++ .../application/console_api/risk_tiers.js | 32 +++++++++++++++---- .../contracts/console-api.schema.json | 18 +++++++++-- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs index 0538c9be..5d22247f 100644 --- a/tests/decision_cockpit_api_validation.mjs +++ b/tests/decision_cockpit_api_validation.mjs @@ -225,6 +225,28 @@ assert.equal(risk.status, 200); assert.equal(risk.body.no_order, true); assert.equal(risk.body.execution_authority_granted, false); assert.ok(Array.isArray(risk.body.tier_bindings)); +for (const tierBinding of risk.body.tier_bindings) { + // Effective tier is an observed readback; with no observation it stays null and says why. + assert.equal(tierBinding.effective_derived_from_approved, false); + assert.equal(tierBinding.effective.observed_at, null); + if (tierBinding.effective.status === "unavailable") { + assert.equal(tierBinding.effective.tier, null); + assert.equal(tierBinding.effective.unavailable_reason, "effective_tier_not_observed"); + assert.equal(tierBinding.effective.source, null); + } else { + assert.equal(tierBinding.effective.status, "observed"); + assert.ok(tierBinding.effective.tier); + } +} +const { riskTierBindingView } = await import("../web/strategy-switch-console/application/console_api/risk_tiers.js"); +const observedTier = riskTierBindingView({ + binding: { profile_selection: { risk_preference: "BALANCED_COMPOUNDING", profile_id: "balanced_compounding_v1" } }, + observation: { tier: "CAPITAL_PRESERVATION", observed_at: now, source: "runtime_readback" }, +}); +assert.equal(observedTier.effective.tier, "CAPITAL_PRESERVATION"); +assert.equal(observedTier.effective.status, "observed"); +assert.equal(observedTier.effective.source, "runtime_readback"); +assert.notEqual(observedTier.effective.tier, observedTier.approved.tier); // Unauthenticated access fails closed. assert.equal((await call("/api/decisions/owner_decision:cockpit-owner", { cookie: "" })).status, 401); diff --git a/web/strategy-switch-console/application/console_api/risk_tiers.js b/web/strategy-switch-console/application/console_api/risk_tiers.js index e330c357..4b274b00 100644 --- a/web/strategy-switch-console/application/console_api/risk_tiers.js +++ b/web/strategy-switch-console/application/console_api/risk_tiers.js @@ -36,7 +36,29 @@ export function riskTierPolicy(tier) { } : null; } -export function riskTierBindingView({ scope_id, platform, target_name, binding, revision = null }) { +/** + * Effective tier is an independently observed runtime readback, never a copy of + * the approved policy (ARCHITECTURE.md 4.4). Without an observation the tier + * stays null and carries an explicit reason code. + */ +export function riskTierEffectiveView(observation) { + const tier = observation && RISK_TIER_POLICY_BINDINGS[observation.tier] ? observation.tier : null; + return tier ? { + tier, + status: "observed", + unavailable_reason: null, + source: observation.source || "runtime_readback", + observed_at: observation.observed_at || null, + } : { + tier: null, + status: "unavailable", + unavailable_reason: "effective_tier_not_observed", + source: null, + observed_at: null, + }; +} + +export function riskTierBindingView({ scope_id, platform, target_name, binding, revision = null, observation = null }) { const tier = binding?.profile_selection?.risk_preference || null; const policy = riskTierPolicy(tier); return { @@ -59,12 +81,8 @@ export function riskTierBindingView({ scope_id, platform, target_name, binding, policy, source: "policy_binding", } : null, - effective: { - tier: null, - status: "unavailable", - unavailable_reason: "effective_tier_not_observed", - source: null, - }, + effective: riskTierEffectiveView(observation), + effective_derived_from_approved: false, no_order: true, execution_authority_granted: false, }; diff --git a/web/strategy-switch-console/contracts/console-api.schema.json b/web/strategy-switch-console/contracts/console-api.schema.json index ba44118c..59391e24 100644 --- a/web/strategy-switch-console/contracts/console-api.schema.json +++ b/web/strategy-switch-console/contracts/console-api.schema.json @@ -4451,7 +4451,10 @@ ] }, "status": { - "type": "string" + "enum": [ + "observed", + "unavailable" + ] }, "unavailable_reason": { "type": [ @@ -4464,15 +4467,25 @@ "string", "null" ] + }, + "observed_at": { + "type": [ + "string", + "null" + ] } }, "required": [ "tier", "status", "unavailable_reason", - "source" + "source", + "observed_at" ] }, + "effective_derived_from_approved": { + "const": false + }, "no_order": { "const": true }, @@ -4488,6 +4501,7 @@ "selected", "approved", "effective", + "effective_derived_from_approved", "no_order", "execution_authority_granted" ] From 350f98fd6792c79ce2fd988a0d88948f53e5cb5a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:57:29 +0800 Subject: [PATCH 06/13] feat(console): paginate the decision inbox with an opaque cursor --- tests/decision_cockpit_api_validation.mjs | 29 ++++++++++ .../application/console_api/inbox.js | 53 +++++++++++++++++-- .../application/console_api/routes.js | 12 ++++- .../contracts/console-api.schema.json | 7 +++ 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs index 5d22247f..1b79874f 100644 --- a/tests/decision_cockpit_api_validation.mjs +++ b/tests/decision_cockpit_api_validation.mjs @@ -262,5 +262,34 @@ assert.equal((await call(actionUrl, { method: "POST", cookie: reader, body: { action: "keep_parked", expected_revision: ownerItem.revision, request_id: "cockpit-request-4", } })).status, 403); +// Inbox pagination: bounded limit, opaque cursor, stable order, null on the last page. +assert.equal((await syncRecovery(recoverySource("cockpit-page-recovery", now))).status, 200); +const fullInbox = await call("/api/decisions-inbox?limit=100"); +assert.equal(fullInbox.status, 200); +assert.equal(fullInbox.body.next_cursor, null, "the fixture inbox fits in one bounded page"); +assert.ok(fullInbox.body.items.length >= 2, "pagination needs at least two items"); +const firstPage = await call("/api/decisions-inbox?limit=1"); +assert.equal(firstPage.status, 200); +assert.equal(firstPage.body.items.length, 1); +assert.equal(typeof firstPage.body.next_cursor, "string"); +assert.match(firstPage.body.next_cursor, /^[A-Za-z0-9_-]+$/, "cursor is opaque, not a raw offset"); +const secondPage = await call(`/api/decisions-inbox?limit=1&cursor=${encodeURIComponent(firstPage.body.next_cursor)}`); +assert.equal(secondPage.status, 200); +assert.equal(secondPage.body.items.length, 1); +assert.deepEqual( + secondPage.body.items.map((item) => item.id), + fullInbox.body.items.slice(1, 2).map((item) => item.id), + "the cursor continues the stable full-page order", +); +const repeatedPage = await call("/api/decisions-inbox?limit=1"); +assert.deepEqual( + repeatedPage.body.items.map((item) => item.id), + firstPage.body.items.map((item) => item.id), + "ordering is deterministic across identical requests", +); +assert.equal((await call("/api/decisions-inbox?limit=0")).status, 400); +assert.equal((await call("/api/decisions-inbox?limit=101")).status, 400); +assert.equal((await call("/api/decisions-inbox?cursor=not-a-cursor!")).status, 400); + await mf.dispose(); console.log("decision_cockpit_api_validation: PASS"); diff --git a/web/strategy-switch-console/application/console_api/inbox.js b/web/strategy-switch-console/application/console_api/inbox.js index c7aa61c0..eabf2079 100644 --- a/web/strategy-switch-console/application/console_api/inbox.js +++ b/web/strategy-switch-console/application/console_api/inbox.js @@ -2,7 +2,43 @@ import { canonicalItem } from "./presentation.js"; import { directoryStatus } from "../../kv_directory.js"; import { readFailure } from "./read_failures.js"; +/** Bounded page size; the inbox is a read projection, not an export. */ +export const INBOX_PAGE_DEFAULT = 25; +export const INBOX_PAGE_MAX = 100; + +const base64url = (value) => + btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +/** Opaque cursor: clients must not derive meaning from it. */ +export const inboxCursor = (offset) => + base64url(JSON.stringify({ v: 1, o: offset })); + +export function inboxCursorOffset(cursor) { + if (typeof cursor !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(cursor)) return null; + try { + const parsed = JSON.parse( + atob(cursor.replace(/-/g, "+").replace(/_/g, "/")), + ); + if (parsed?.v !== 1 || !Number.isSafeInteger(parsed.o) || parsed.o < 0) return null; + return parsed.o; + } catch { + return null; + } +} + +export function inboxPage(url) { + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null || rawLimit === "" ? INBOX_PAGE_DEFAULT : Number(rawLimit); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > INBOX_PAGE_MAX) + throw new Error("invalid_pagination"); + const rawCursor = url.searchParams.get("cursor"); + const offset = rawCursor === null || rawCursor === "" ? 0 : inboxCursorOffset(rawCursor); + if (!Number.isSafeInteger(offset)) throw new Error("invalid_pagination"); + return { limit, offset }; +} + export async function inbox(request, env, deps) { + const { limit, offset } = inboxPage(new URL(request.url)); const definitions = [ ["owner", "owner_decision", "candidates"], ["promotion", "strategy_promotion", "tickets"], @@ -70,6 +106,15 @@ export async function inbox(request, env, deps) { results.flatMap((r) => r.items).map((item) => [item.id, item]), ); const priority = { high: 0, medium: 1, normal: 2 }; + // Stable, deterministic order: priority, then age, then id. The id tiebreak + // keeps equal priority and timestamp rows from reordering between pages. + const ordered = [...unique.values()].sort( + (a, b) => + (priority[a.priority] ?? 3) - (priority[b.priority] ?? 3) || + (a.created_at || "").localeCompare(b.created_at || "") || + String(a.id ?? "").localeCompare(String(b.id ?? "")), + ); + const items = ordered.slice(offset, offset + limit); return { schema_version: "qsl.decision_inbox.v1", server_time: new Date().toISOString(), @@ -83,11 +128,9 @@ export async function inbox(request, env, deps) { sources: results.map(({ source, status, failure_reason, error_state, current_reconciliation_observation }) => ({ source, status, ...(current_reconciliation_observation ? { current_reconciliation_observation } : {}), ...(failure_reason ? { failure_reason, error_state } : {}) })), - items: [...unique.values()].sort( - (a, b) => - priority[a.priority] - priority[b.priority] || - (a.created_at || "").localeCompare(b.created_at || ""), - ), + items, + next_cursor: + ordered.length > offset + limit ? inboxCursor(offset + limit) : null, coverage: "existing_decision_sources", no_order: true, execution_authority_granted: false, diff --git a/web/strategy-switch-console/application/console_api/routes.js b/web/strategy-switch-console/application/console_api/routes.js index 1eba4bfb..f6c7cb17 100644 --- a/web/strategy-switch-console/application/console_api/routes.js +++ b/web/strategy-switch-console/application/console_api/routes.js @@ -30,6 +30,16 @@ export async function consoleApiResponse(request, env, deps) { || [...url.searchParams.keys()].some(key => key !== "include"))) { return json({ ok: false, error: "invalid_accounts_include" }, 400); } + // The inbox owns its opaque cursor; the numeric page cursor below does not apply. + if (path === "/api/decisions-inbox") { + try { + return json(await inbox(request, env, deps)); + } catch (error) { + if (error?.message === "invalid_pagination") + return json({ ok: false, error: "invalid_pagination" }, 400); + throw error; + } + } let page; try { page = pageQuery(url); @@ -53,8 +63,6 @@ export async function consoleApiResponse(request, env, deps) { coverage: "durable_human_decision_records", }); } - if (path === "/api/decisions-inbox") - return json(await inbox(request, env, deps)); if (path === "/api/notifications/send-log") return json(await notificationLog(env, deps)); const config = await deps.accountOptions(env); const accounts = Object.entries(config.options || {}).flatMap( diff --git a/web/strategy-switch-console/contracts/console-api.schema.json b/web/strategy-switch-console/contracts/console-api.schema.json index 59391e24..ff54c57a 100644 --- a/web/strategy-switch-console/contracts/console-api.schema.json +++ b/web/strategy-switch-console/contracts/console-api.schema.json @@ -533,6 +533,13 @@ ] } }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Opaque cursor for the next inbox page; null when the page is the last." + }, "coverage": { "type": "string" }, From e566259efe327641ebaf10b920265c92fd32d402 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:02:16 +0800 Subject: [PATCH 07/13] feat(console): give decision action requests a stable operation id Reject a reused operation id with a different intent instead of replaying the recorded decision, and echo the operation identity in the result. --- tests/decision_cockpit_api_validation.mjs | 26 ++++++++++- .../console_api/decision_contract.js | 30 ++++++++++++ .../contracts/console-api.schema.json | 14 ++++++ web/strategy-switch-console/worker.js | 46 +++++++++++++++---- 4 files changed, 105 insertions(+), 11 deletions(-) diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs index 1b79874f..87cadcfa 100644 --- a/tests/decision_cockpit_api_validation.mjs +++ b/tests/decision_cockpit_api_validation.mjs @@ -162,6 +162,8 @@ assert.equal(decided.body.ok, true); assert.equal(decided.body.decision_id, ownerItem.decision_id); assert.equal(decided.body.decision_state.status, "decided"); assert.match(decided.body.decision_receipt.receipt_sha256, /^[0-9a-f]{64}$/); +assert.equal(decided.body.operation_id, "cockpit-request-1"); +assert.match(decided.body.operation_sha256, /^[0-9a-f]{64}$/); assert.equal(decided.body.policy.no_order, true); const replayed = await call(actionUrl, { method: "POST", body: { action: "keep_parked", expected_revision: ownerItem.revision, request_id: "cockpit-request-1", @@ -169,11 +171,33 @@ const replayed = await call(actionUrl, { method: "POST", body: { assert.equal(replayed.status, 200); assert.equal(replayed.body.replayed, true); assert.equal(replayed.body.decision_receipt.receipt_sha256, decided.body.decision_receipt.receipt_sha256); +// A retry under the same operation id returns the same operation identity. +assert.equal(replayed.body.operation_id, decided.body.operation_id); +assert.equal(replayed.body.operation_sha256, decided.body.operation_sha256); +// Reusing the recorded operation id for a different intent is rejected, not replayed. +const reusedOperation = await call(actionUrl, { method: "POST", body: { + action: "approve_limited_live_canary", expected_revision: ownerItem.revision, operation_id: "cockpit-request-1", +} }); +assert.equal(reusedOperation.status, 409); +assert.equal(reusedOperation.body.error, "decision_operation_conflict"); +assert.equal(reusedOperation.body.operation_id, "cockpit-request-1"); +// A malformed or mismatched operation id never reaches the durable writer. +const badOperation = await call(actionUrl, { method: "POST", body: { + action: "keep_parked", expected_revision: ownerItem.revision, operation_id: "not a valid id", +} }); +assert.equal(badOperation.status, 400); +assert.equal(badOperation.body.error, "decision_operation_id_invalid"); +const mismatchedOperation = await call(actionUrl, { method: "POST", body: { + action: "keep_parked", expected_revision: ownerItem.revision, + operation_id: "cockpit-op-a", request_id: "cockpit-request-b", +} }); +assert.equal(mismatchedOperation.status, 400); +assert.equal(mismatchedOperation.body.error, "decision_operation_id_invalid"); const conflictingAction = await call(actionUrl, { method: "POST", body: { action: "approve_limited_live_canary", expected_revision: ownerItem.revision, request_id: "cockpit-request-2", } }); assert.equal(conflictingAction.status, 409); -assert.equal(conflictingAction.body.error, "human_decision_conflict"); +assert.equal(conflictingAction.body.error, "decision_operation_conflict"); const decidedReview = await call(`/api/decisions/${encodeURIComponent(ownerItem.decision_id)}`); assert.equal(decidedReview.body.current_eligibility.eligible, false); assert.equal(decidedReview.body.decision.allowed_actions.length, 0); diff --git a/web/strategy-switch-console/application/console_api/decision_contract.js b/web/strategy-switch-console/application/console_api/decision_contract.js index a112f66a..ec0648f9 100644 --- a/web/strategy-switch-console/application/console_api/decision_contract.js +++ b/web/strategy-switch-console/application/console_api/decision_contract.js @@ -233,6 +233,36 @@ export function operationIdFor(category, subjectId) { return subjectId ? `operation:${category}:${subjectId}` : null; } +/** Client-supplied identity of one decision action intent. */ +export const DECISION_OPERATION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; + +/** + * The stable operation id of an action request. `request_id` stays the transport + * retry id; `operation_id` names the intent itself, so a retried intent keeps one + * identity while a genuinely new intent must pick a new id. + */ +export function decisionOperationId(body) { + const operationId = typeof body?.operation_id === "string" ? body.operation_id.trim() : ""; + const requestId = typeof body?.request_id === "string" ? body.request_id.trim() : ""; + if (operationId && requestId && operationId !== requestId) return null; + const id = operationId || requestId; + return DECISION_OPERATION_ID_PATTERN.test(id) ? id : null; +} + +/** Digest of the action intent, so a reused operation id is told apart from a retry. */ +export async function decisionOperationSha256(decisionId, body) { + const selection = body?.selection && typeof body.selection === "object" ? body.selection : null; + const canonical = JSON.stringify([ + String(decisionId || ""), + String(body?.action || ""), + selection ? [String(selection.confirmation || ""), String(selection.selected_account || "")] : null, + ]); + const bytes = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)), + ); + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + export function operationStatusFromApplication(application) { if (!application) return "unapplied"; if (application.status === "approved") diff --git a/web/strategy-switch-console/contracts/console-api.schema.json b/web/strategy-switch-console/contracts/console-api.schema.json index ff54c57a..9a9c8e15 100644 --- a/web/strategy-switch-console/contracts/console-api.schema.json +++ b/web/strategy-switch-console/contracts/console-api.schema.json @@ -4154,6 +4154,20 @@ "action": { "type": "string" }, + "operation_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "operation_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "recorded_operation_id": { + "type": [ + "string", + "null" + ] + }, "decision_receipt": { "type": [ "object", diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 5583e374..60a2db08 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -11,7 +11,8 @@ import { decorateQueue, decisionFields, actorAlias, pageQuery } from "./applicat import { inbox } from "./application/console_api/inbox.js"; import { DECISION_ACTIONS, DECISION_REVIEW_SCHEMA_VERSION, decisionCategoryPart, decisionCurrentEligibility, - decisionExpiry, decisionExpiryState, decisionKind, decisionRevision, decisionWriteCategory, + decisionExpiry, decisionExpiryState, decisionKind, decisionOperationId, decisionOperationSha256, + decisionRevision, decisionWriteCategory, operationIdFor, operationStatusFromApplication, } from "./application/console_api/decision_contract.js"; import { RISK_TIER_POLICY_BINDINGS, riskTierBindingView } from "./application/console_api/risk_tiers.js"; @@ -12905,7 +12906,7 @@ async function decisionActionResponse(request, env, rawId) { } if (!raw || Array.isArray(raw) || typeof raw !== "object") return json({ ok: false, error: "decision_action_request_invalid" }, 400); - if (Object.keys(raw).some((key) => !["action", "expected_revision", "expected_expires_at", "request_id", "selection"].includes(key))) + if (Object.keys(raw).some((key) => !["action", "expected_revision", "expected_expires_at", "request_id", "operation_id", "selection"].includes(key))) return json({ ok: false, error: "decision_action_request_invalid" }, 400); const action = typeof raw.action === "string" ? raw.action.trim() : ""; if (!supported.includes(action)) @@ -12913,8 +12914,15 @@ async function decisionActionResponse(request, env, rawId) { if (typeof raw.expected_revision !== "string" || !/^rev[0-9]-[0-9a-z-]{1,64}$/.test(raw.expected_revision)) return json({ ok: false, error: "decision_revision_required" }, 400); const requestId = typeof raw.request_id === "string" ? raw.request_id.trim() : ""; - if (!/^[A-Za-z0-9._:-]{1,128}$/.test(requestId)) - return json({ ok: false, error: "invalid request_id" }, 400); + // The operation id names the intent. It is the durable idempotency key, so a + // retry with the same id replays and a new intent needs a new id. + const operationId = decisionOperationId(raw); + if (!operationId) { + if (requestId && !/^[A-Za-z0-9._:-]{1,128}$/.test(requestId)) + return json({ ok: false, error: "invalid request_id" }, 400); + return json({ ok: false, error: "decision_operation_id_invalid" }, 400); + } + const operationSha256 = await decisionOperationSha256(parsed.id, raw); const selection = decisionSelection(raw); if ((parsed.category === "strategy_promotion" || parsed.category === "paper_account_application") && action === "accept" && (!selection?.confirmation || !selection?.selected_account)) @@ -12938,8 +12946,22 @@ async function decisionActionResponse(request, env, rawId) { const materialKey = item.decision_binding?.material_sha256 || null; const existing = authorityRaw?.decision; if (existing && materialKey && existing.material_key === materialKey) { - if (existing.kind !== kind || existing.subject_id !== parsed.subject_id || existing.action !== action) - return json({ ok: false, error: "human_decision_conflict", decision_id: item.id, server_time: serverTime }, 409); + // The durable record keeps the operation id that produced it. + const recordedOperationId = typeof existing.payload?.request_id === "string" ? existing.payload.request_id + : typeof existing.payload?.requestId === "string" ? existing.payload.requestId + : null; + // A retry of the recorded intent replays; any other intent on the same + // material is a conflicting operation, whether or not it reuses the id. + if (existing.kind !== kind || existing.subject_id !== parsed.subject_id || existing.action !== action) { + return json({ + ok: false, + error: "decision_operation_conflict", + decision_id: item.id, + operation_id: operationId, + recorded_operation_id: recordedOperationId, + server_time: serverTime, + }, 409); + } const receipt = await humanDecisionReceipt(existing, { kind, subject_id: parsed.subject_id, material_sha256: materialKey, }); @@ -12950,6 +12972,8 @@ async function decisionActionResponse(request, env, rawId) { decision_id: item.id, revision: item.revision, action, + operation_id: operationId, + operation_sha256: operationSha256, decision_receipt: receipt, decided_at: existing.decided_at, decision_state: { status: "decided", action, decided_at: existing.decided_at }, @@ -12988,7 +13012,7 @@ async function decisionActionResponse(request, env, rawId) { candidate_id: parsed.subject_id, decision: action, candidate_evidence_sha256: materialKey, - request_id: requestId, + request_id: operationId, }; } else if (parsed.category === "reconciliation_recovery") { targetPath = "/api/reconciliation-recovery-confirmations"; @@ -12999,7 +13023,7 @@ async function decisionActionResponse(request, env, rawId) { candidate_sha256: item.initial_enrollment.candidate_sha256, facts_sha256: item.initial_enrollment.facts_sha256, decision: action, - request_id: requestId, + request_id: operationId, }; } else { innerBody = { @@ -13007,7 +13031,7 @@ async function decisionActionResponse(request, env, rawId) { candidate_sha256: item.evidence_summary?.candidate_sha256, dual_review_binding_sha256: item.evidence_summary?.dual_review_binding_sha256, decision: action, - request_id: requestId, + request_id: operationId, }; } } else { @@ -13032,7 +13056,7 @@ async function decisionActionResponse(request, env, rawId) { confirmation: selection.confirmation, selected_account: selection.selected_account, } : {}), - request_id: requestId, + request_id: operationId, }; } @@ -13071,6 +13095,8 @@ async function decisionActionResponse(request, env, rawId) { decision_id: item.id, revision: item.revision, action, + operation_id: operationId, + operation_sha256: operationSha256, decision_receipt: payload.decision_receipt || null, decided_at: decidedAt, decision_state: { status: "decided", action, decided_at: decidedAt }, From 6fb9546bdeea31aa808443f389da3878a8514849 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:08:23 +0800 Subject: [PATCH 08/13] test(console): validate decision review and action envelopes against published schema --- tests/console_api_contract_validation.mjs | 43 +++++++++++++++++++++++ tests/decision_cockpit_api_validation.mjs | 14 ++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/tests/console_api_contract_validation.mjs b/tests/console_api_contract_validation.mjs index a7a875e7..bdaabf41 100644 --- a/tests/console_api_contract_validation.mjs +++ b/tests/console_api_contract_validation.mjs @@ -76,6 +76,49 @@ validate("assets", { ...assetFixture, curve_status: "partial", curve_coverage: { validate("assets", { ...assetFixture, curve_coverage: null }); assert.throws(() => validate("assets", { ...assetFixture, curve_status: "healthy" }), /curve_status: enum/); assert.throws(() => validate("assets", { ...assetFixture, curve_coverage: { forward_filled_points: "1" } }), /curve_coverage: anyOf/); +// The decision cockpit read and action envelopes are published schema contracts. +const reviewEnvelope = { + schema_version: "qsl.decision_review.v1", + server_time: "2026-10-15T12:00:00Z", + data_status: "ready", + decision_id: "owner_decision:synthetic", + decision: {}, + current_eligibility: { eligible: true, material_matches: true, expired: false, blocked: false, reason_codes: [] }, + decision_authority: null, + decision_receipt: null, + operation: null, + policy: {}, + errors: [], +}; +validate("decision_review", reviewEnvelope); +const { server_time: _reviewTime, ...reviewWithoutTime } = reviewEnvelope; +assert.throws(() => validate("decision_review", reviewWithoutTime), /server_time: required/); +assert.throws( + () => validate("decision_review", { ...reviewEnvelope, current_eligibility: { eligible: "yes" } }), + /current_eligibility\.material_matches: required/, +); +const actionEnvelope = { + schema_version: "qsl.decision_action_result.v1", + server_time: "2026-10-15T12:00:00Z", + ok: true, + replayed: false, + decision_id: "owner_decision:synthetic", + revision: "rev1-0123456789abcdef", + action: "keep_parked", + operation_id: "synthetic-operation", + operation_sha256: "a".repeat(64), + decision_receipt: null, + decided_at: null, + decision_state: {}, + operation: null, + policy: {}, +}; +validate("decision_action_result", actionEnvelope); +assert.throws(() => validate("decision_action_result", { ...actionEnvelope, revision: "r1" }), /decision_action_result\.revision/); +assert.throws( + () => validate("decision_action_result", { ...actionEnvelope, schema_version: "qsl.decision_action_result.v2" }), + /schema_version/, +); const endpoints = { "/api/decisions-inbox": "inbox", "/api/owner-decisions/history": "decision_history", diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs index 87cadcfa..050f3c9f 100644 --- a/tests/decision_cockpit_api_validation.mjs +++ b/tests/decision_cockpit_api_validation.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { __test } from "./helpers/directory_worker.mjs"; +import { validate } from "./console_api_contract_validation.mjs"; const require = createRequire(new URL("../web/strategy-switch-console/package.json", import.meta.url)); const { Miniflare } = require(process.env.QRT_MINIFLARE_MODULE || "miniflare"); @@ -138,8 +139,13 @@ assert.equal(review.body.decision_id, ownerItem.decision_id); assert.equal(review.body.current_eligibility.eligible, true); assert.equal(review.body.current_eligibility.expired, false); assert.equal(review.body.decision.decision_id, ownerItem.decision_id); -assert.equal((await call("/api/decisions/owner_decision:missing")).status, 404); -assert.equal((await call("/api/decisions/not-a-decision")).status, 400); +validate("decision_review", review.body); +const missingReview = await call("/api/decisions/owner_decision:missing"); +assert.equal(missingReview.status, 404); +assert.equal(missingReview.body.error, "decision_not_found"); +const malformedReview = await call("/api/decisions/not-a-decision"); +assert.equal(malformedReview.status, 400); +assert.equal(malformedReview.body.error, "decision_id_invalid"); // POST /api/decisions/:id/actions delegates to the durable owner command. const actionUrl = `/api/decisions/${encodeURIComponent(ownerItem.decision_id)}/actions`; @@ -165,11 +171,13 @@ assert.match(decided.body.decision_receipt.receipt_sha256, /^[0-9a-f]{64}$/); assert.equal(decided.body.operation_id, "cockpit-request-1"); assert.match(decided.body.operation_sha256, /^[0-9a-f]{64}$/); assert.equal(decided.body.policy.no_order, true); +validate("decision_action_result", decided.body); const replayed = await call(actionUrl, { method: "POST", body: { action: "keep_parked", expected_revision: ownerItem.revision, request_id: "cockpit-request-1", } }); assert.equal(replayed.status, 200); assert.equal(replayed.body.replayed, true); +validate("decision_action_result", replayed.body); assert.equal(replayed.body.decision_receipt.receipt_sha256, decided.body.decision_receipt.receipt_sha256); // A retry under the same operation id returns the same operation identity. assert.equal(replayed.body.operation_id, decided.body.operation_id); @@ -200,8 +208,10 @@ assert.equal(conflictingAction.status, 409); assert.equal(conflictingAction.body.error, "decision_operation_conflict"); const decidedReview = await call(`/api/decisions/${encodeURIComponent(ownerItem.decision_id)}`); assert.equal(decidedReview.body.current_eligibility.eligible, false); +assert.ok(decidedReview.body.current_eligibility.reason_codes.includes("decision_already_decided")); assert.equal(decidedReview.body.decision.allowed_actions.length, 0); assert.equal(decidedReview.body.decision.decision_state.status, "decided"); +validate("decision_review", decidedReview.body); // Operations resolve a recorded decision without resubmitting it. const operation = await call(`/api/operations/${encodeURIComponent("operation:owner_decision:cockpit-owner")}`); From 884b8f17e34b24c9a0345c6e67fd5101a8564993 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:28:49 +0800 Subject: [PATCH 09/13] feat(console): expose declarative tier policy parameters The cockpit now returns every declarative tier policy (tier id, composer_mdd_multiple, promotion_size_scale) from the inbox and the decision review. The catalog is projected from the single risk_tiers.js source, so no numbers are duplicated. Each entry stays labeled owner intent only: executable false, activation_status not_connected, no_order true and execution_authority_granted false. An unknown tier still resolves to null rather than a numeric default, and effective.tier behavior is unchanged. --- tests/console_api_contract_validation.mjs | 32 +++++++++ tests/decision_cockpit_api_validation.mjs | 39 ++++++++++- .../application/console_api/inbox.js | 2 + .../application/console_api/risk_tiers.js | 14 ++++ .../contracts/console-api.schema.json | 69 +++++++++++++++++++ web/strategy-switch-console/worker.js | 3 +- 6 files changed, 157 insertions(+), 2 deletions(-) diff --git a/tests/console_api_contract_validation.mjs b/tests/console_api_contract_validation.mjs index bdaabf41..c2588aac 100644 --- a/tests/console_api_contract_validation.mjs +++ b/tests/console_api_contract_validation.mjs @@ -97,6 +97,38 @@ assert.throws( () => validate("decision_review", { ...reviewEnvelope, current_eligibility: { eligible: "yes" } }), /current_eligibility\.material_matches: required/, ); +// Tier-policy parameters are published as declarative owner intent only. +const tierPolicyEnvelope = { + schema_version: "qsl.risk_tier_policy.v1", + tier: "CAPITAL_PRESERVATION", + policy_id: "capital_preservation_v1", + profile_id: "capital_preservation_v1", + composer_mdd_multiple: 1, + promotion_size_scale: 0.5, + executable: false, + activation_status: "not_connected", + owner_intent_only: true, + no_order: true, + execution_authority_granted: false, +}; +validate("risk_tier_policy", tierPolicyEnvelope); +validate("decision_review", { ...reviewEnvelope, tier_policies: [tierPolicyEnvelope] }); +assert.throws( + () => validate("risk_tier_policy", { ...tierPolicyEnvelope, composer_mdd_multiple: "1.0" }), + /composer_mdd_multiple: type/, +); +assert.throws( + () => validate("risk_tier_policy", { ...tierPolicyEnvelope, tier: "UNKNOWN_TIER" }), + /tier: enum/, +); +assert.throws( + () => validate("risk_tier_policy", { ...tierPolicyEnvelope, executable: true }), + /executable/, +); +assert.throws( + () => validate("risk_tier_policy", { ...tierPolicyEnvelope, execution_authority_granted: true }), + /execution_authority_granted/, +); const actionEnvelope = { schema_version: "qsl.decision_action_result.v1", server_time: "2026-10-15T12:00:00Z", diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs index 050f3c9f..0ea72083 100644 --- a/tests/decision_cockpit_api_validation.mjs +++ b/tests/decision_cockpit_api_validation.mjs @@ -117,6 +117,7 @@ const owner = ownerSnapshot("cockpit-owner"); assert.equal((await syncControl(owner)).status, 200); let inbox = await call("/api/decisions-inbox"); assert.equal(inbox.status, 200); +validate("inbox", inbox.body); assert.equal(typeof inbox.body.server_time, "string"); const ownerItem = inbox.body.items.find((item) => item.decision_id === "owner_decision:cockpit-owner"); assert.ok(ownerItem, "owner decision item is present"); @@ -272,7 +273,43 @@ for (const tierBinding of risk.body.tier_bindings) { assert.ok(tierBinding.effective.tier); } } -const { riskTierBindingView } = await import("../web/strategy-switch-console/application/console_api/risk_tiers.js"); +const { riskTierPolicy, riskTierPolicyCatalog, riskTierBindingView, RISK_TIER_POLICY_BINDINGS } = + await import("../web/strategy-switch-console/application/console_api/risk_tiers.js"); +// Declarative tier policies are readable owner intent, never order input. +const tierPolicies = inbox.body.tier_policies; +assert.ok(Array.isArray(tierPolicies), "the inbox exposes the tier-policy catalog"); +assert.deepEqual( + tierPolicies.map((entry) => entry.tier).sort(), + Object.keys(RISK_TIER_POLICY_BINDINGS).sort(), + "every declarative tier appears exactly once", +); +for (const entry of tierPolicies) { + const binding = RISK_TIER_POLICY_BINDINGS[entry.tier]; + assert.equal(entry.schema_version, "qsl.risk_tier_policy.v1"); + assert.equal(typeof entry.tier, "string"); + assert.equal(entry.policy_id, binding.policy_id); + assert.equal(entry.profile_id, binding.profile_id); + assert.equal(entry.composer_mdd_multiple, binding.composer_mdd_multiple); + assert.equal(entry.promotion_size_scale, binding.promotion_size_scale); + assert.equal(typeof entry.composer_mdd_multiple, "number"); + assert.equal(typeof entry.promotion_size_scale, "number"); + assert.equal(entry.executable, false); + assert.equal(entry.activation_status, "not_connected"); + assert.equal(entry.owner_intent_only, true); + assert.equal(entry.no_order, true); + assert.equal(entry.execution_authority_granted, false); +} +assert.deepEqual(review.body.tier_policies, tierPolicies, "the review carries the same catalog"); +// An unknown tier fails closed instead of falling back to a numeric policy. +assert.equal(riskTierPolicy("UNKNOWN_TIER"), null); +const unknownTier = riskTierBindingView({ + binding: { profile_selection: { risk_preference: "UNKNOWN_TIER", profile_id: "unknown_v1" } }, + observation: { tier: "UNKNOWN_TIER" }, +}); +assert.equal(unknownTier.approved, null); +assert.equal(unknownTier.effective.tier, null); +assert.equal(unknownTier.effective.status, "unavailable"); +assert.equal(unknownTier.effective.unavailable_reason, "effective_tier_not_observed"); const observedTier = riskTierBindingView({ binding: { profile_selection: { risk_preference: "BALANCED_COMPOUNDING", profile_id: "balanced_compounding_v1" } }, observation: { tier: "CAPITAL_PRESERVATION", observed_at: now, source: "runtime_readback" }, diff --git a/web/strategy-switch-console/application/console_api/inbox.js b/web/strategy-switch-console/application/console_api/inbox.js index eabf2079..f41c35f7 100644 --- a/web/strategy-switch-console/application/console_api/inbox.js +++ b/web/strategy-switch-console/application/console_api/inbox.js @@ -1,4 +1,5 @@ import { canonicalItem } from "./presentation.js"; +import { riskTierPolicyCatalog } from "./risk_tiers.js"; import { directoryStatus } from "../../kv_directory.js"; import { readFailure } from "./read_failures.js"; @@ -132,6 +133,7 @@ export async function inbox(request, env, deps) { next_cursor: ordered.length > offset + limit ? inboxCursor(offset + limit) : null, coverage: "existing_decision_sources", + tier_policies: riskTierPolicyCatalog(), no_order: true, execution_authority_granted: false, }; diff --git a/web/strategy-switch-console/application/console_api/risk_tiers.js b/web/strategy-switch-console/application/console_api/risk_tiers.js index 4b274b00..d62a2bae 100644 --- a/web/strategy-switch-console/application/console_api/risk_tiers.js +++ b/web/strategy-switch-console/application/console_api/risk_tiers.js @@ -36,6 +36,20 @@ export function riskTierPolicy(tier) { } : null; } +/** + * Declarative tier policies as owner intent only. Nothing consumes these + * numbers for orders, so every entry keeps the non-executable flags and an + * unknown tier resolves to null instead of a numeric default. + */ +export function riskTierPolicyCatalog() { + return Object.keys(RISK_TIER_POLICY_BINDINGS).map((tier) => ({ + ...riskTierPolicy(tier), + owner_intent_only: true, + no_order: true, + execution_authority_granted: false, + })); +} + /** * Effective tier is an independently observed runtime readback, never a copy of * the approved policy (ARCHITECTURE.md 4.4). Without an observation the tier diff --git a/web/strategy-switch-console/contracts/console-api.schema.json b/web/strategy-switch-console/contracts/console-api.schema.json index 9a9c8e15..7fa1af33 100644 --- a/web/strategy-switch-console/contracts/console-api.schema.json +++ b/web/strategy-switch-console/contracts/console-api.schema.json @@ -543,6 +543,13 @@ "coverage": { "type": "string" }, + "tier_policies": { + "type": "array", + "description": "Declarative tier policies. Owner intent only; never applied to orders.", + "items": { + "$ref": "#/$defs/risk_tier_policy" + } + }, "no_order": { "const": true }, @@ -4066,6 +4073,61 @@ "reason_codes" ] }, + "risk_tier_policy": { + "type": "object", + "properties": { + "schema_version": { + "const": "qsl.risk_tier_policy.v1" + }, + "tier": { + "enum": [ + "CAPITAL_PRESERVATION", + "BALANCED_COMPOUNDING", + "GROWTH_COMPOUNDING" + ] + }, + "policy_id": { + "type": "string" + }, + "profile_id": { + "type": "string" + }, + "composer_mdd_multiple": { + "type": "number" + }, + "promotion_size_scale": { + "type": "number" + }, + "executable": { + "const": false + }, + "activation_status": { + "const": "not_connected" + }, + "owner_intent_only": { + "const": true + }, + "no_order": { + "const": true + }, + "execution_authority_granted": { + "const": false + } + }, + "required": [ + "schema_version", + "tier", + "policy_id", + "profile_id", + "composer_mdd_multiple", + "promotion_size_scale", + "executable", + "activation_status", + "owner_intent_only", + "no_order", + "execution_authority_granted" + ] + }, "decision_review": { "type": "object", "properties": { @@ -4111,6 +4173,13 @@ "policy": { "type": "object" }, + "tier_policies": { + "type": "array", + "description": "Declarative tier policies. Owner intent only; never applied to orders.", + "items": { + "$ref": "#/$defs/risk_tier_policy" + } + }, "errors": { "type": "array", "items": { diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 60a2db08..a65daa7e 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -15,7 +15,7 @@ import { decisionRevision, decisionWriteCategory, operationIdFor, operationStatusFromApplication, } from "./application/console_api/decision_contract.js"; -import { RISK_TIER_POLICY_BINDINGS, riskTierBindingView } from "./application/console_api/risk_tiers.js"; +import { RISK_TIER_POLICY_BINDINGS, riskTierBindingView, riskTierPolicyCatalog } from "./application/console_api/risk_tiers.js"; import { recoveryMaterialPresentation, currentReconciliationObservationPresentation } from "./application/console_api/reconciliation_presentation.js"; // deploy: 2026-06-30 — config driven by platform-config.json import { CredentialMaintenanceError, initializeCredentialMaintenance, credentialMaintenanceCommand } from "./credentialMaintenance.js"; @@ -12872,6 +12872,7 @@ async function decisionReviewResponse(request, env, rawId) { decision_receipt: receipt, operation: item.operation_reference, policy: DECISION_POLICY, + tier_policies: riskTierPolicyCatalog(), errors: [...new Set(errors)], }); } From 8e1316f78204edacf463ee2bb94b9c3ed70a4291 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:42:38 +0800 Subject: [PATCH 10/13] fix(console): reuse the observed promotion authority Co-Authored-By: Codex --- tests/console_evidence_metadata_validation.mjs | 11 +++++++++++ web/strategy-switch-console/worker.js | 17 ++++------------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/console_evidence_metadata_validation.mjs b/tests/console_evidence_metadata_validation.mjs index 7c31bbbb..da7df9ba 100644 --- a/tests/console_evidence_metadata_validation.mjs +++ b/tests/console_evidence_metadata_validation.mjs @@ -129,6 +129,17 @@ assert.equal(JSON.stringify(queue).includes("never-public"), false); assert.equal(fixture.commands.filter(command => command?.action === "promotion_read").length, 1, "the existing authority observation is reused exactly once"); assert.equal(fixture.reads.filter(name => name === `research_promotion_ticket:${ticket.ticket_id}`).length, 1); assert.equal(fixture.writes(), 0); +const materialKey = "a".repeat(64); +const expiresAt = "2026-11-02T00:00:00Z"; +const authorityFixture = readFixture({ migrated: true, eligible: true, blocked: false, material_key: materialKey, expires_at: expiresAt, decision: null }); +const authorityQueue = await (await queueRead(authorityFixture, "synthetic-reader")).json(); +assert.deepEqual(authorityQueue.tickets[0].decision_authority, { + material_sha256: materialKey, expires_at: expiresAt, eligible: true, blocked: false, + unavailable: false, source: "durable_human_decision_material", +}, "the public authority view retains the original durable observation"); +assert.deepEqual(authorityQueue.applications[0].decision_authority, authorityQueue.tickets[0].decision_authority); +assert.equal(authorityFixture.commands.filter(command => command?.action === "promotion_read").length, 1); +assert.equal(authorityFixture.writes(), 0); for (const login of [null, "synthetic-denied"]) { const denied = readFixture({ migrated: true, eligible: true, blocked: false }); assert.equal((await queueRead(denied, login)).status, 401); diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index a65daa7e..d8c29e4d 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -12632,6 +12632,10 @@ async function listResearchPromotionTickets(env) { const authority = await readHumanDecisionAuthority(env, "promotion", record.ticket_id); const ticket = attachPromotionQueueReadMetadata(record, overlayPromotionDecision(record, authority), authority); if (ticket.live_authority_granted) continue; + if (ticket.state === "awaiting_human") { + // Project the same durable observation used by the decision metadata. + ticket.decision_authority = humanDecisionAuthorityView(authority); + } tickets.push(ticket); } catch (error) { if (error instanceof HttpError && error.status === 503) throw error; @@ -12694,19 +12698,6 @@ async function researchPromotionTicketsResponse(request, env) { } }); } const awaiting = tickets.filter((ticket) => ticket.state === "awaiting_human"); - // Current authority is read from the durable material, never inferred from the - // ticket body; an unreadable authority leaves the ticket visibly non-actionable. - if (hasRuntimeInstanceStore(env)) { - await Promise.all(awaiting.map(async (ticket) => { - try { - ticket.decision_authority = humanDecisionAuthorityView( - await readHumanDecisionAuthority(env, "promotion", ticket.ticket_id), - ); - } catch { - ticket.decision_authority = null; - } - })); - } const applicationTickets = tickets.filter((ticket) => ticket.state === "awaiting_human" || ticket.state === "human_accepted"); const accountConfig = await loadAccountOptionsConfig(env); From 58e9d143524572cc4f2027b92586367b77f9ecd0 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:41:44 +0800 Subject: [PATCH 11/13] fix(deps): patch console tooling security advisories Co-Authored-By: Codex --- .../frontend/package-lock.json | 8 +- .../frontend/package.json | 2 +- web/strategy-switch-console/package-lock.json | 305 ++++++++---------- web/strategy-switch-console/package.json | 3 +- 4 files changed, 138 insertions(+), 180 deletions(-) diff --git a/web/strategy-switch-console/frontend/package-lock.json b/web/strategy-switch-console/frontend/package-lock.json index 6974b71e..410a733c 100644 --- a/web/strategy-switch-console/frontend/package-lock.json +++ b/web/strategy-switch-console/frontend/package-lock.json @@ -15,7 +15,7 @@ "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "typescript": "5.9.2", - "vite": "7.3.6" + "vite": "7.3.7" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1161,9 +1161,9 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/strategy-switch-console/frontend/package.json b/web/strategy-switch-console/frontend/package.json index eaa4ba39..110a9700 100644 --- a/web/strategy-switch-console/frontend/package.json +++ b/web/strategy-switch-console/frontend/package.json @@ -18,6 +18,6 @@ "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "typescript": "5.9.2", - "vite": "7.3.6" + "vite": "7.3.7" } } diff --git a/web/strategy-switch-console/package-lock.json b/web/strategy-switch-console/package-lock.json index b86fa425..69ce5f26 100644 --- a/web/strategy-switch-console/package-lock.json +++ b/web/strategy-switch-console/package-lock.json @@ -129,9 +129,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", - "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -148,13 +148,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.1" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", - "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -171,13 +171,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.1" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", - "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -185,7 +185,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -195,9 +195,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", - "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -212,9 +212,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", - "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -229,16 +229,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", - "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -249,16 +246,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", - "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -269,16 +263,13 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", - "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -289,16 +280,13 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", - "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -309,16 +297,13 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", - "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -329,16 +314,13 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", - "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -349,16 +331,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", - "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -369,16 +348,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", - "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -389,16 +365,13 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", - "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -411,20 +384,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.1" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", - "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -437,20 +407,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.1" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", - "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -463,20 +430,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.1" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", - "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -489,20 +453,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.1" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", - "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -515,20 +476,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.1" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", - "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -541,20 +499,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.1" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", - "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -567,20 +522,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", - "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -593,18 +545,18 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", - "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -614,9 +566,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", - "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], @@ -624,7 +576,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -634,9 +586,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", - "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -654,9 +606,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", - "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -674,9 +626,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", - "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -849,15 +801,15 @@ } }, "node_modules/sharp": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", - "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "dev": true, "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.8.4" + "semver": "^7.8.5" }, "engines": { "node": ">=20.9.0" @@ -866,31 +818,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.2", - "@img/sharp-darwin-x64": "0.35.2", - "@img/sharp-freebsd-wasm32": "0.35.2", - "@img/sharp-libvips-darwin-arm64": "1.3.1", - "@img/sharp-libvips-darwin-x64": "1.3.1", - "@img/sharp-libvips-linux-arm": "1.3.1", - "@img/sharp-libvips-linux-arm64": "1.3.1", - "@img/sharp-libvips-linux-ppc64": "1.3.1", - "@img/sharp-libvips-linux-riscv64": "1.3.1", - "@img/sharp-libvips-linux-s390x": "1.3.1", - "@img/sharp-libvips-linux-x64": "1.3.1", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", - "@img/sharp-libvips-linuxmusl-x64": "1.3.1", - "@img/sharp-linux-arm": "0.35.2", - "@img/sharp-linux-arm64": "0.35.2", - "@img/sharp-linux-ppc64": "0.35.2", - "@img/sharp-linux-riscv64": "0.35.2", - "@img/sharp-linux-s390x": "0.35.2", - "@img/sharp-linux-x64": "0.35.2", - "@img/sharp-linuxmusl-arm64": "0.35.2", - "@img/sharp-linuxmusl-x64": "0.35.2", - "@img/sharp-webcontainers-wasm32": "0.35.2", - "@img/sharp-win32-arm64": "0.35.2", - "@img/sharp-win32-ia32": "0.35.2", - "@img/sharp-win32-x64": "0.35.2" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/supports-color": { @@ -907,17 +864,17 @@ } }, "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", "dev": true, "license": "0BSD", "optional": true }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { diff --git a/web/strategy-switch-console/package.json b/web/strategy-switch-console/package.json index f2fa9ce1..d51f89cd 100644 --- a/web/strategy-switch-console/package.json +++ b/web/strategy-switch-console/package.json @@ -7,7 +7,8 @@ }, "overrides": { "miniflare": { - "undici": "7.29.0" + "undici": "7.30.1", + "sharp": "0.35.5" } } } From c42f99d629d7118b0cde92042ea7eb26c22bb6e9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:31:44 +0800 Subject: [PATCH 12/13] fix(console): resolve decisions by id beyond the inbox page Decision detail and action routes searched only the first 25-item inbox page, so a decision on a later page was reported as decision_not_found. They now read the unpaged projection. The operation digest also serialised selection objects with String(), collapsing both to "[object Object]" regardless of account or paper/live mode. It now serialises the same account and confirmation fields the durable record binds, in a fixed order. The decision cockpit API validation now covers both cases and runs in the validate workflow. --- .github/workflows/validate.yml | 1 + tests/decision_cockpit_api_validation.mjs | 55 +++++++++++++++++++ .../console_api/decision_contract.js | 27 ++++++++- .../application/console_api/inbox.js | 13 +++-- web/strategy-switch-console/worker.js | 4 +- 5 files changed, 93 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5407642a..cb296632 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -234,6 +234,7 @@ jobs: node tests/console_api_contract_validation.mjs node tests/console_binance_current_holdings_validation.mjs node tests/console_api_worker_validation.mjs + node tests/decision_cockpit_api_validation.mjs node tests/console_design_api_validation.mjs node web/strategy-switch-console/frontend/node_modules/typescript/bin/tsc -p tests/tsconfig.console-gaps.json node --experimental-strip-types tests/console_gap_contract_validation.mjs diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs index 0ea72083..a43181b6 100644 --- a/tests/decision_cockpit_api_validation.mjs +++ b/tests/decision_cockpit_api_validation.mjs @@ -7,6 +7,7 @@ import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { __test } from "./helpers/directory_worker.mjs"; import { validate } from "./console_api_contract_validation.mjs"; +import { decisionOperationSha256 } from "../web/strategy-switch-console/application/console_api/decision_contract.js"; const require = createRequire(new URL("../web/strategy-switch-console/package.json", import.meta.url)); const { Miniflare } = require(process.env.QRT_MINIFLARE_MODULE || "miniflare"); @@ -30,6 +31,35 @@ const bindings = { STRATEGY_SWITCH_STRATEGY_PROFILES_JSON: JSON.stringify(profiles), }; const adminCookie = `qsl_switch_session=${await __test.makeSession("cockpit-admin", [], bindings)}`; + +// The operation digest binds the selected account and execution mode. Two +// selections that only differ in account or paper/live must not share a digest. +const selectionDigest = (selection) => decisionOperationSha256("strategy_promotion:cockpit-digest", { action: "accept", selection }); +const liveSelection = { + confirmation: { target_platform: "longbridge", execution_mode: "live", risk_profile: "CAPITAL_PRESERVATION" }, + selected_account: { platform: "longbridge", key: "paper" }, +}; +const paperSelection = { + confirmation: { target_platform: "longbridge", execution_mode: "paper", risk_profile: "CAPITAL_PRESERVATION" }, + selected_account: { platform: "longbridge", key: "paper" }, +}; +const otherAccountSelection = { + confirmation: { target_platform: "longbridge", execution_mode: "live", risk_profile: "CAPITAL_PRESERVATION" }, + selected_account: { platform: "longbridge", key: "live" }, +}; +const liveDigest = await selectionDigest(liveSelection); +assert.match(liveDigest, /^[0-9a-f]{64}$/); +assert.notEqual(await selectionDigest(paperSelection), liveDigest, "paper and live must not share an operation digest"); +assert.notEqual(await selectionDigest(otherAccountSelection), liveDigest, "different accounts must not share an operation digest"); +assert.equal( + await selectionDigest({ + selected_account: { key: "paper", platform: "longbridge" }, + confirmation: { risk_profile: "CAPITAL_PRESERVATION", execution_mode: "live", target_platform: "longbridge" }, + }), + liveDigest, + "the digest must not depend on property order", +); + const options = { cf: false, modules: true, @@ -362,5 +392,30 @@ assert.equal((await call("/api/decisions-inbox?limit=0")).status, 400); assert.equal((await call("/api/decisions-inbox?limit=101")).status, 400); assert.equal((await call("/api/decisions-inbox?cursor=not-a-cursor!")).status, 400); +// By-id detail and action lookups address the unpaged projection, so a decision +// that falls beyond the default first page is still reviewable and actionable. +const bulkIds = Array.from({ length: 26 }, (_, index) => `cockpit-bulk-${String(index + 1).padStart(2, "0")}`); +const bulkBase = ownerSnapshot(bulkIds[0]); +const bulkSnapshot = { + ...bulkBase, + summary: { ...bulkBase.summary, candidate_count: bulkIds.length, owner_decision_required: bulkIds.length }, + candidates: bulkIds.map((candidateId) => ({ ...bulkBase.candidates[0], candidate_id: candidateId })), +}; +assert.equal((await syncControl(bulkSnapshot)).status, 200); +const beyondId = `owner_decision:${bulkIds[bulkIds.length - 1]}`; +const defaultPage = await call("/api/decisions-inbox"); +assert.equal(defaultPage.body.items.length, 25); +assert.ok(!defaultPage.body.items.some((item) => item.id === beyondId), "the fixture decision is beyond the default page"); +const unpagedInbox = await call("/api/decisions-inbox?limit=100"); +assert.ok(unpagedInbox.body.items.some((item) => item.id === beyondId), "the fixture decision exists in the full projection"); +const beyondReview = await call(`/api/decisions/${encodeURIComponent(beyondId)}`); +assert.equal(beyondReview.status, 200); +assert.equal(beyondReview.body.decision_id, beyondId); +const beyondAction = await call(`/api/decisions/${encodeURIComponent(beyondId)}/actions`, { method: "POST", body: { + action: "keep_parked", expected_revision: "rev0-00000000", operation_id: "cockpit-bulk-operation", +} }); +assert.equal(beyondAction.status, 409, "the action route reaches revision validation instead of reporting a missing decision"); +assert.equal(beyondAction.body.error, "decision_revision_conflict"); + await mf.dispose(); console.log("decision_cockpit_api_validation: PASS"); diff --git a/web/strategy-switch-console/application/console_api/decision_contract.js b/web/strategy-switch-console/application/console_api/decision_contract.js index ec0648f9..d1332852 100644 --- a/web/strategy-switch-console/application/console_api/decision_contract.js +++ b/web/strategy-switch-console/application/console_api/decision_contract.js @@ -249,13 +249,36 @@ export function decisionOperationId(body) { return DECISION_OPERATION_ID_PATTERN.test(id) ? id : null; } +/** + * The durable record binds exactly these selection fields. The operation digest + * serialises the same projection so an account or paper/live switch always + * changes the intent identity instead of collapsing to "[object Object]". + */ +const SELECTION_CANONICAL_FIELDS = Object.freeze({ + confirmation: Object.freeze(["target_platform", "execution_mode", "risk_profile"]), + selected_account: Object.freeze(["platform", "key"]), +}); + +function canonicalSelectionPart(value, fields) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return fields.map((field) => (typeof value[field] === "string" ? value[field] : null)); +} + +function canonicalSelection(selection) { + if (!selection || typeof selection !== "object" || Array.isArray(selection)) return null; + // Fixed key order: the digest must not depend on property insertion order. + return { + confirmation: canonicalSelectionPart(selection.confirmation, SELECTION_CANONICAL_FIELDS.confirmation), + selected_account: canonicalSelectionPart(selection.selected_account, SELECTION_CANONICAL_FIELDS.selected_account), + }; +} + /** Digest of the action intent, so a reused operation id is told apart from a retry. */ export async function decisionOperationSha256(decisionId, body) { - const selection = body?.selection && typeof body.selection === "object" ? body.selection : null; const canonical = JSON.stringify([ String(decisionId || ""), String(body?.action || ""), - selection ? [String(selection.confirmation || ""), String(selection.selected_account || "")] : null, + canonicalSelection(body?.selection), ]); const bytes = new Uint8Array( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)), diff --git a/web/strategy-switch-console/application/console_api/inbox.js b/web/strategy-switch-console/application/console_api/inbox.js index f41c35f7..baf9ba6e 100644 --- a/web/strategy-switch-console/application/console_api/inbox.js +++ b/web/strategy-switch-console/application/console_api/inbox.js @@ -38,8 +38,13 @@ export function inboxPage(url) { return { limit, offset }; } -export async function inbox(request, env, deps) { - const { limit, offset } = inboxPage(new URL(request.url)); +/** + * Read the inbox projection. List routes take one bounded page; by-id detail + * and action lookups pass `paginate: false` because a decision id is not + * guaranteed to be on the first page. + */ +export async function inbox(request, env, deps, { paginate = true } = {}) { + const page = paginate ? inboxPage(new URL(request.url)) : null; const definitions = [ ["owner", "owner_decision", "candidates"], ["promotion", "strategy_promotion", "tickets"], @@ -115,7 +120,7 @@ export async function inbox(request, env, deps) { (a.created_at || "").localeCompare(b.created_at || "") || String(a.id ?? "").localeCompare(String(b.id ?? "")), ); - const items = ordered.slice(offset, offset + limit); + const items = page ? ordered.slice(page.offset, page.offset + page.limit) : ordered; return { schema_version: "qsl.decision_inbox.v1", server_time: new Date().toISOString(), @@ -131,7 +136,7 @@ export async function inbox(request, env, deps) { ...(failure_reason ? { failure_reason, error_state } : {}) })), items, next_cursor: - ordered.length > offset + limit ? inboxCursor(offset + limit) : null, + page && ordered.length > page.offset + page.limit ? inboxCursor(page.offset + page.limit) : null, coverage: "existing_decision_sources", tier_policies: riskTierPolicyCatalog(), no_order: true, diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index d8c29e4d..ef94a5a3 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -12806,7 +12806,9 @@ function decisionSourceUnreadable(queue, category) { } async function loadDecisionFromInbox(request, env, id) { - const queue = await inbox(request, env, decisionInboxSources()); + // Detail and action routes address one decision by id. Reading the unpaged + // projection keeps them independent of the presentation page a client last saw. + const queue = await inbox(request, env, decisionInboxSources(), { paginate: false }); return { queue, item: queue.items.find((item) => item.id === id) || null }; } From dc737613a583af68bbb67423bcc76fd273dd4443 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:29:57 +0800 Subject: [PATCH 13/13] fix(console): bind decision replay to the recorded operation intent Replay trusted only kind, subject and action, so any retry on the same material received a success receipt. Verify the durable operation id and the canonical selection before replaying. Resolve terminal decisions from the durable record after projection recategorization or removal, and map promotion application audit rows to the promotion event type. --- tests/decision_cockpit_api_validation.mjs | 153 ++++++++++++++++++++++ web/strategy-switch-console/worker.js | 128 +++++++++++++++--- 2 files changed, 264 insertions(+), 17 deletions(-) diff --git a/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs index a43181b6..916c3611 100644 --- a/tests/decision_cockpit_api_validation.mjs +++ b/tests/decision_cockpit_api_validation.mjs @@ -47,6 +47,26 @@ const otherAccountSelection = { confirmation: { target_platform: "longbridge", execution_mode: "live", risk_profile: "CAPITAL_PRESERVATION" }, selected_account: { platform: "longbridge", key: "live" }, }; +const v7Profile = profiles.find((item) => + item.profile === "soxl_soxx_core_only_p2_v7_longterm_compounding_cash_reserve"); +assert.ok(v7Profile?.research_candidate_identity); +const producerNotes = [ + "frozen_v7_forward_financial_gates_passed_research_only", + `forward_record_sha256=${"a".repeat(64)}`, `financial_evidence_sha256=${"b".repeat(64)}`, + `p1_manifest_sha256=${"c".repeat(64)}`, `p4_policy_sha256=${"d".repeat(64)}`, + `baseline_p3_evidence_sha256=${"e".repeat(64)}`, +]; +function promotionTicket(ticketId) { + return { + ticket_id: ticketId, strategy_profile: v7Profile.profile, domain: "us_equity", state: "awaiting_human", + drift_status: "not_applicable", drift_score: 0, created_at: "2026-09-13T00:00:00Z", updated_at: "2026-09-13T00:00:00Z", + budget: {}, proposed_params: { ...v7Profile.research_candidate_identity }, search_iterations: 0, + shadow_evidence_kind: "v7_nonlive_shadow_and_simulated_paper", shadow_passed: true, + notification_subject: "V7", notification_body: "fixture", human_decision: "", human_decided_at: "", + live_authority_granted: false, suggested_risk_profile: "CAPITAL_PRESERVATION", confirmation_target_platform: "", + confirmation_execution_mode: "", confirmation_risk_profile: "", notes: [...producerNotes], + }; +} const liveDigest = await selectionDigest(liveSelection); assert.match(liveDigest, /^[0-9a-f]{64}$/); assert.notEqual(await selectionDigest(paperSelection), liveDigest, "paper and live must not share an operation digest"); @@ -243,6 +263,89 @@ 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); +// 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", +} }); +assert.equal(otherOperationSameAction.status, 409); +assert.equal(otherOperationSameAction.body.error, "decision_operation_conflict"); +assert.equal(otherOperationSameAction.body.recorded_operation_id, "cockpit-request-1"); + +// Promotion replay binds the recorded operation id, material and full selection. +const promotionId = `rpt_${"8".repeat(64)}`; +const promotion = promotionTicket(promotionId); +assert.equal((await syncPromotion(promotion)).status, 200); +inbox = await call("/api/decisions-inbox"); +const promotionItem = inbox.body.items.find((item) => item.id === `strategy_promotion:${promotionId}`); +assert.ok(promotionItem, "promotion ticket is actionable"); +const promotionActionUrl = `/api/decisions/${encodeURIComponent(promotionItem.id)}/actions`; +const promotionAcceptBody = { + action: "accept", expected_revision: promotionItem.revision, expected_expires_at: promotionItem.expires_at, + operation_id: "cockpit-promotion-1", selection: paperSelection, +}; +const promotionAccepted = await call(promotionActionUrl, { method: "POST", body: promotionAcceptBody }); +assert.equal(promotionAccepted.status, 200); +assert.equal(promotionAccepted.body.replayed, false); +assert.equal(promotionAccepted.body.operation_id, "cockpit-promotion-1"); +assert.match(promotionAccepted.body.decision_receipt.receipt_sha256, /^[0-9a-f]{64}$/); +// The original decision id still resolves after the paper recategorization. +const acceptedOriginalId = await call(`/api/decisions/${encodeURIComponent(promotionItem.id)}`); +assert.equal(acceptedOriginalId.status, 200); +assert.equal(acceptedOriginalId.body.decision_id, promotionItem.id); +assert.equal(acceptedOriginalId.body.decision.decision_state.status, "decided"); +assert.equal(acceptedOriginalId.body.decision.decision_state.action, "accept"); +assert.equal(acceptedOriginalId.body.current_eligibility.eligible, false); +assert.equal(acceptedOriginalId.body.decision_receipt.receipt_sha256, promotionAccepted.body.decision_receipt.receipt_sha256); +validate("decision_review", acceptedOriginalId.body); +// An exact retry replays; the receipt and operation id stay those of the record. +const promotionReplay = await call(promotionActionUrl, { method: "POST", body: promotionAcceptBody }); +assert.equal(promotionReplay.status, 200); +assert.equal(promotionReplay.body.replayed, true); +assert.equal(promotionReplay.body.operation_id, "cockpit-promotion-1"); +assert.equal(promotionReplay.body.decision_id, promotionItem.id); +assert.equal(promotionReplay.body.decision_receipt.receipt_sha256, promotionAccepted.body.decision_receipt.receipt_sha256); +// The same operation id with a different account/mode selection is not a replay. +const changedSelectionReplay = await call(promotionActionUrl, { method: "POST", body: { + ...promotionAcceptBody, operation_id: "cockpit-promotion-1", selection: otherAccountSelection, +} }); +assert.equal(changedSelectionReplay.status, 409); +assert.equal(changedSelectionReplay.body.error, "decision_operation_conflict"); +assert.equal(changedSelectionReplay.body.recorded_operation_id, "cockpit-promotion-1"); +// The recorded selection under a new operation id is a new intent, not a replay. +const newOperationReplay = await call(promotionActionUrl, { method: "POST", body: { + ...promotionAcceptBody, operation_id: "cockpit-promotion-2", +} }); +assert.equal(newOperationReplay.status, 409); +assert.equal(newOperationReplay.body.error, "decision_operation_conflict"); +assert.equal(newOperationReplay.body.recorded_operation_id, "cockpit-promotion-1"); + +// A rejected promotion leaves the pending queue but keeps the original id resolvable. +const rejectedId = `rpt_${"7".repeat(64)}`; +assert.equal((await syncPromotion(promotionTicket(rejectedId))).status, 200); +inbox = await call("/api/decisions-inbox"); +const rejectedItem = inbox.body.items.find((item) => item.id === `strategy_promotion:${rejectedId}`); +assert.ok(rejectedItem); +const rejectActionUrl = `/api/decisions/${encodeURIComponent(rejectedItem.id)}/actions`; +// A reject ignores selection, so a retry that still carries it must replay. +const rejectBody = { + action: "reject", expected_revision: rejectedItem.revision, + expected_expires_at: rejectedItem.expires_at, operation_id: "cockpit-promotion-reject", + selection: paperSelection, +}; +const rejected = await call(rejectActionUrl, { method: "POST", body: rejectBody }); +assert.equal(rejected.status, 200); +const afterReject = await call("/api/decisions-inbox?limit=100"); +assert.ok(!afterReject.body.items.some((item) => item.id === rejectedItem.id), "rejected ticket leaves the pending projection"); +const rejectedReview = await call(`/api/decisions/${encodeURIComponent(rejectedItem.id)}`); +assert.equal(rejectedReview.status, 200); +assert.equal(rejectedReview.body.decision_id, rejectedItem.id); +assert.equal(rejectedReview.body.decision.decision_state.action, "reject"); +assert.equal(rejectedReview.body.decision_receipt.receipt_sha256, rejected.body.decision_receipt.receipt_sha256); +const rejectedReplay = await call(rejectActionUrl, { method: "POST", body: rejectBody }); +assert.equal(rejectedReplay.status, 200); +assert.equal(rejectedReplay.body.replayed, true); +assert.equal(rejectedReplay.body.decision_id, rejectedItem.id); +assert.equal(rejectedReplay.body.decision_receipt.receipt_sha256, rejected.body.decision_receipt.receipt_sha256); // Operations resolve a recorded decision without resubmitting it. const operation = await call(`/api/operations/${encodeURIComponent("operation:owner_decision:cockpit-owner")}`); @@ -264,6 +367,56 @@ const auditPage = await call("/api/audit-events?limit=1"); assert.equal(auditPage.body.events.length, 1); assert.equal(auditPage.body.next_cursor, "1"); assert.equal((await call("/api/audit-events?cursor=bad")).status, 400); +// Event types are derived from the normalized source for all three classes. +assert.equal(__test.auditEventAction("durable_human_decision", "promotion"), "record_promotion_decision"); +assert.equal(__test.auditEventAction("research_promotion_application"), "research_promotion_application"); +assert.equal(__test.auditEventAction("account_settings_application"), "account_settings_approval"); + +// A promotion application must not be reported as an account-settings approval. +const initializedRuntime = await call("/api/admin/runtime-instances", { method: "POST", body: { + action: "initialize", expected_revision: 0, confirm: "IMPORT_EXISTING_CONFIG", +} }); +assert.equal(initializedRuntime.status, 200); +const auditTicketId = `rpt_${"4".repeat(64)}`; +const auditTicket = promotionTicket(auditTicketId); +assert.equal((await syncPromotion(auditTicket)).status, 200); +inbox = await call("/api/decisions-inbox"); +const auditPromotionItem = inbox.body.items.find((item) => item.id === `strategy_promotion:${auditTicketId}`); +assert.ok(auditPromotionItem); +const auditAccepted = await call(`/api/decisions/${encodeURIComponent(auditPromotionItem.id)}/actions`, { method: "POST", body: { + action: "accept", expected_revision: auditPromotionItem.revision, + expected_expires_at: auditPromotionItem.expires_at, + operation_id: "cockpit-audit-promotion", selection: paperSelection, +} }); +assert.equal(auditAccepted.status, 200); +const namespace = await mf.getDurableObjectNamespace("STRATEGY_SWITCH_RUNTIME_INSTANCES"); +const runtimeStub = namespace.get(namespace.idFromName("runtime-instances")); +const auditRevision = (await call("/api/admin/runtime-instances")).body.revision; +const applicationCreated = await runtimeStub.fetch("https://runtime-instances/", { method: "POST", body: JSON.stringify({ + action: "application_create", actor: "cockpit-admin", ticket_id: auditTicketId, + promotion_material_key: await __test.researchPromotionMaterialKey(auditTicket), + platform_id: "longbridge", account_key: "paper", + application_id: "application-cockpit-audit", claim_token: "claim-cockpit-audit", + account_scope: paperAccount.account_scope, account_selector: paperAccount.account_selector, + service_name: paperAccount.service_name, broker_environment: paperAccount.broker_environment, + expected_revision: auditRevision, expected_strategy_profile: paperAccount.default_strategy_profile, + strategy_profile: v7Profile.profile, candidate_id: v7Profile.research_candidate_identity.candidate_id, + config_sha256: v7Profile.research_candidate_identity.config_sha256, + source_commit: "a".repeat(40), approved_ues_revision: "fixture-ues", created_at: now, +}) }); +const createdApplication = await applicationCreated.json(); +assert.equal(applicationCreated.status, 200, JSON.stringify(createdApplication)); +assert.equal(createdApplication.created, true); +const auditAfterPromotion = await call("/api/audit-events?limit=100"); +const promotionApplicationEvent = auditAfterPromotion.body.events.find((event) => + event.source === "research_promotion_application" && event.subject.id === auditTicketId); +assert.ok(promotionApplicationEvent, "the promotion application row is audited"); +assert.equal(promotionApplicationEvent.action, "research_promotion_application"); +assert.notEqual(promotionApplicationEvent.action, "account_settings_approval"); +const promotionDecisionEvent = auditAfterPromotion.body.events.find((event) => + event.source === "durable_human_decision" && event.subject.id === auditTicketId); +assert.ok(promotionDecisionEvent, "the promotion decision record is audited"); +assert.equal(promotionDecisionEvent.action, "record_promotion_decision"); // Expired material is visible but never actionable, and the action route fails closed. const expiredAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"); diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index ef94a5a3..068058cf 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -7,12 +7,12 @@ import { strategyTargetExecution, accountExecutionEvidence } from "./application import { performanceResponse } from "./application/console_api/performance_routes.js"; import { consoleApiResponse } from "./application/console_api/routes.js"; import { runControls } from "./application/console_api/run_controls.js"; -import { decorateQueue, decisionFields, actorAlias, pageQuery } from "./application/console_api/presentation.js"; +import { decorateQueue, decisionFields, actorAlias, pageQuery, shortId } from "./application/console_api/presentation.js"; import { inbox } from "./application/console_api/inbox.js"; import { DECISION_ACTIONS, DECISION_REVIEW_SCHEMA_VERSION, decisionCategoryPart, decisionCurrentEligibility, - decisionExpiry, decisionExpiryState, decisionKind, decisionOperationId, decisionOperationSha256, - decisionRevision, decisionWriteCategory, + decisionExpiry, decisionExpiryState, decisionKind, decisionOperationId, decisionOperationReference, + decisionOperationSha256, decisionRevision, decisionWriteCategory, operationIdFor, operationStatusFromApplication, } from "./application/console_api/decision_contract.js"; import { RISK_TIER_POLICY_BINDINGS, riskTierBindingView, riskTierPolicyCatalog } from "./application/console_api/risk_tiers.js"; @@ -12805,11 +12805,85 @@ function decisionSourceUnreadable(queue, category) { return ["read_failed", "unavailable"].includes(source.status); } -async function loadDecisionFromInbox(request, env, id) { +// The durable record keeps the operation id in its request key. The id names the +// recorded intent; a legacy fingerprint key carries no client operation id. +function recordedDecisionOperationId(record) { + const match = /^id:(?:owner|promotion|recovery):(.+)$/.exec( + typeof record?.request_key === "string" ? record.request_key : "", + ); + return match ? match[1] : null; +} + +// The recorded intent's canonical target: accepted selection for promotion, action elsewhere. +function decisionIntentTargetJson(category, action, selection) { + if (category === "strategy_promotion" || category === "paper_account_application") { + const accepted = action === "accept" ? selection : null; + return canonicalResearchTaskJson({ + selected_account: accepted?.selected_account ?? null, + confirmation: accepted?.confirmation ?? null, + }); + } + return canonicalResearchTaskJson({ decision: action }); +} + +// A terminal item synthesized from the durable record. It keeps the original +// decision id resolvable after the pending projection drops or recategorizes it. +function durableDecisionItem(record, parsed, kind, aliasItem = null) { + const material = /^[0-9a-f]{64}$/.test(record.material_key || "") ? record.material_key : null; + const reviewSha = /^[0-9a-f]{64}$/.test(record.payload?.review_binding?.review_sha256 || "") + ? record.payload.review_binding.review_sha256 : null; + const binding = { + kind, + subject_id: record.subject_id, + material_sha256: material, + ...(reviewSha ? { review_binding: { schema_version: "qsl_promotion_review_binding.v2", review_sha256: reviewSha } } : {}), + }; + return { + id: parsed.id, + short_id: shortId(parsed.id), + decision_id: parsed.id, + category: parsed.category, + title_key: `decisions.${parsed.category}`, + revision: decisionRevision(binding, parsed.id), + actionable: false, + blocking_reason_codes: ["decision_already_decided"], + allowed_actions: [], + request_id_required: false, + action_endpoint: null, + decision_state: { + status: "decided", + action: record.action, + decided_at: typeof record.decided_at === "string" ? record.decided_at : null, + }, + decision_binding: binding, + operation_reference: aliasItem?.operation_reference + || decisionOperationReference({}, parsed.category, parsed.subject_id), + source: "durable_human_decision_record", + }; +} + +async function loadDecisionById(request, env, parsed) { // Detail and action routes address one decision by id. Reading the unpaged // projection keeps them independent of the presentation page a client last saw. const queue = await inbox(request, env, decisionInboxSources(), { paginate: false }); - return { queue, item: queue.items.find((item) => item.id === id) || null }; + const item = queue.items.find((entry) => entry.id === parsed.id) || null; + if (item) return { queue, item, authorityRaw: undefined }; + // A terminal record outlives the pending projection: accepted paper tickets are + // recategorized and rejected tickets leave the queue, so resolve them durably. + const kind = decisionKind(parsed.category); + if (!kind || !hasRuntimeInstanceStore(env)) return { queue, item: null, authorityRaw: undefined }; + let authorityRaw; + try { + authorityRaw = await readHumanDecisionAuthority(env, kind, parsed.subject_id); + } catch { + throw new HttpError("decision_authority_unavailable", 503); + } + const record = authorityRaw?.migrated === false ? null : authorityRaw?.decision; + if (!record || record.kind !== kind || record.subject_id !== parsed.subject_id) + return { queue, item: null, authorityRaw }; + const alias = queue.items.find((entry) => entry.decision_binding?.subject_id === parsed.subject_id + && entry.decision_binding?.kind === kind); + return { queue, item: durableDecisionItem(record, parsed, kind, alias || null), authorityRaw }; } async function readDecisionAuthorityOrNull(env, kind, subjectId) { @@ -12824,7 +12898,7 @@ async function decisionReviewResponse(request, env, rawId) { if (!session?.allowed) return json({ ok: false, error: "login required", error_state: "unauthorized" }, 401); const parsed = decodeDecisionApiId(rawId); if (!parsed) return json({ ok: false, error: "decision_id_invalid" }, 400); - const { queue, item } = await loadDecisionFromInbox(request, env, parsed.id); + const { queue, item, authorityRaw: loadedAuthority } = await loadDecisionById(request, env, parsed); if (!item) { if (decisionSourceUnreadable(queue, parsed.category)) return json({ ok: false, error: "decision_source_unavailable", error_state: queue.error_state || null }, 503); @@ -12833,7 +12907,9 @@ async function decisionReviewResponse(request, env, rawId) { const kind = decisionKind(parsed.category); let authorityRaw; try { - authorityRaw = await readDecisionAuthorityOrNull(env, kind, parsed.subject_id); + authorityRaw = loadedAuthority !== undefined + ? loadedAuthority + : await readDecisionAuthorityOrNull(env, kind, parsed.subject_id); } catch { return json({ ok: false, error: "decision_authority_unavailable" }, 503); } @@ -12922,7 +12998,7 @@ async function decisionActionResponse(request, env, rawId) { && action === "accept" && (!selection?.confirmation || !selection?.selected_account)) return json({ ok: false, error: "decision_selection_required" }, 400); - const { queue, item } = await loadDecisionFromInbox(request, env, parsed.id); + const { queue, item } = await loadDecisionById(request, env, parsed); if (!item) { if (decisionSourceUnreadable(queue, parsed.category)) return json({ ok: false, error: "decision_source_unavailable", error_state: queue.error_state || null }, 503); @@ -12940,13 +13016,22 @@ async function decisionActionResponse(request, env, rawId) { const materialKey = item.decision_binding?.material_sha256 || null; const existing = authorityRaw?.decision; if (existing && materialKey && existing.material_key === materialKey) { - // The durable record keeps the operation id that produced it. - const recordedOperationId = typeof existing.payload?.request_id === "string" ? existing.payload.request_id - : typeof existing.payload?.requestId === "string" ? existing.payload.requestId - : null; - // A retry of the recorded intent replays; any other intent on the same - // material is a conflicting operation, whether or not it reuses the id. - if (existing.kind !== kind || existing.subject_id !== parsed.subject_id || existing.action !== action) { + // The durable request key keeps the operation id that produced the record. + const recordedOperationId = recordedDecisionOperationId(existing); + // A retry replays only when the recorded operation id and the complete + // canonical intent match. Any other intent on the same material conflicts, + // whether it reuses the id or presents a new one with the same action. + let recordedTarget = null; + try { + recordedTarget = canonicalResearchTaskJson(JSON.parse(existing.target_json || "{}")); + } catch { + recordedTarget = null; + } + const intentMatches = recordedOperationId !== null && recordedOperationId === operationId + && recordedTarget !== null + && recordedTarget === decisionIntentTargetJson(parsed.category, action, selection); + if (existing.kind !== kind || existing.subject_id !== parsed.subject_id || existing.action !== action + || !intentMatches) { return json({ ok: false, error: "decision_operation_conflict", @@ -13183,6 +13268,15 @@ async function operationResponse(request, env, rawId) { }); } +// Event type by normalized projection source. Comparing the normalized source +// keeps a promotion application from being reported as an account-settings approval. +function auditEventAction(source, kind) { + if (source === "durable_human_decision") return `record_${kind}_decision`; + if (source === "research_promotion_application") return "research_promotion_application"; + if (source === "account_settings_application") return "account_settings_approval"; + return null; +} + function auditEventSubjectId(row) { if (typeof row.ticket_id === "string" && row.ticket_id) return { kind: "strategy_promotion", id: row.ticket_id }; if (typeof row.recovery_id === "string" && row.recovery_id) return { kind: "reconciliation_recovery", id: row.recovery_id }; @@ -13234,8 +13328,7 @@ async function auditEventsResponse(request, env, url) { id: `${source}:${row.ref}`, source, occurred_at: occurredAt, - action: source === "durable_human_decision" ? `record_${row.kind}_decision` - : source === "promotion_application" ? "research_promotion_application" : "account_settings_approval", + action: auditEventAction(source, row.kind), actor_alias: await actorAlias(row.actor || null), subject, revision, @@ -18305,6 +18398,7 @@ function escapeHtml(value) { } export const __test = { + auditEventAction, effectiveAccountSettings, dispatchAccountDiagnosis, humanDecisionReceipt,