diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6d3f8945..9cf68be0 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -235,6 +235,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/console_api_contract_validation.mjs b/tests/console_api_contract_validation.mjs index a7a875e7..c2588aac 100644 --- a/tests/console_api_contract_validation.mjs +++ b/tests/console_api_contract_validation.mjs @@ -76,6 +76,81 @@ 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/, +); +// 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", + 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/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/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/tests/decision_cockpit_api_validation.mjs b/tests/decision_cockpit_api_validation.mjs new file mode 100644 index 00000000..916c3611 --- /dev/null +++ b/tests/decision_cockpit_api_validation.mjs @@ -0,0 +1,574 @@ +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"; +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"); +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)}`; + +// 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 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"); +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, + 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); +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"); +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); +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`; +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.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); +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, "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); +// 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")}`); +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); +// 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"); +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)); +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 { 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" }, +}); +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); +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); + +// 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); + +// 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/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..d1332852 --- /dev/null +++ b/web/strategy-switch-console/application/console_api/decision_contract.js @@ -0,0 +1,343 @@ +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; +} + +/** 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; +} + +/** + * 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 canonical = JSON.stringify([ + String(decisionId || ""), + String(body?.action || ""), + canonicalSelection(body?.selection), + ]); + 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") + 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..baf9ba6e 100644 --- a/web/strategy-switch-console/application/console_api/inbox.js +++ b/web/strategy-switch-console/application/console_api/inbox.js @@ -1,8 +1,50 @@ import { canonicalItem } from "./presentation.js"; +import { riskTierPolicyCatalog } from "./risk_tiers.js"; import { directoryStatus } from "../../kv_directory.js"; import { readFailure } from "./read_failures.js"; -export async function inbox(request, env, deps) { +/** 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 }; +} + +/** + * 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"], @@ -70,7 +112,18 @@ 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 = page ? ordered.slice(page.offset, page.offset + page.limit) : ordered; 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") @@ -81,12 +134,11 @@ 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: + page && ordered.length > page.offset + page.limit ? inboxCursor(page.offset + page.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/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..d62a2bae --- /dev/null +++ b/web/strategy-switch-console/application/console_api/risk_tiers.js @@ -0,0 +1,103 @@ +/** 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; +} + +/** + * 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 + * 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 { + 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: riskTierEffectiveView(observation), + effective_derived_from_approved: false, + 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 5c6ef8e4..7fa1af33 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,13 +516,40 @@ "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" ] } }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Opaque cursor for the next inbox page; null when the page is the last." + }, "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 }, @@ -725,6 +1061,12 @@ "effective": { "type": "object" }, + "risk": { + "type": "object" + }, + "tier_binding": { + "$ref": "#/$defs/risk_tier_binding" + }, "adopted": { "const": false }, @@ -3678,6 +4020,581 @@ ] } } + }, + "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" + ] + }, + "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": { + "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" + }, + "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": { + "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" + }, + "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", + "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": { + "enum": [ + "observed", + "unavailable" + ] + }, + "unavailable_reason": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "observed_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "tier", + "status", + "unavailable_reason", + "source", + "observed_at" + ] + }, + "effective_derived_from_approved": { + "const": false + }, + "no_order": { + "const": true + }, + "execution_authority_granted": { + "const": false + } + }, + "required": [ + "schema_version", + "scope_id", + "platform", + "target_name", + "selected", + "approved", + "effective", + "effective_derived_from_approved", + "no_order", + "execution_authority_granted" + ] } } } 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" } } } diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index a0193710..7f8f67fe 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -8,7 +8,15 @@ 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, 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, 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"; 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"; @@ -180,6 +188,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; @@ -928,6 +941,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); } @@ -3678,6 +3707,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); } @@ -4367,7 +4400,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)) { @@ -4634,6 +4667,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); @@ -4904,7 +4953,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 @@ -4961,7 +5010,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) { @@ -4976,19 +5028,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, @@ -5433,6 +5489,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, @@ -5603,6 +5666,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, @@ -7721,10 +7791,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) || []; @@ -7766,7 +7861,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, }); } @@ -7843,7 +7938,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, }); } @@ -7904,7 +7999,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, }], }); @@ -8019,6 +8114,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; @@ -8290,24 +8403,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) { @@ -8865,37 +8979,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) { @@ -9092,6 +9209,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; } @@ -9107,6 +9225,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) }, }); } @@ -12612,6 +12731,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; @@ -12717,6 +12840,654 @@ 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); +} + +// 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 }); + 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) { + 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, 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); + return json({ ok: false, error: "decision_not_found" }, 404); + } + const kind = decisionKind(parsed.category); + let authorityRaw; + try { + authorityRaw = loadedAuthority !== undefined + ? loadedAuthority + : 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, + tier_policies: riskTierPolicyCatalog(), + 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", "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)) + 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() : ""; + // 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)) + return json({ ok: false, error: "decision_selection_required" }, 400); + + 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); + 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) { + // 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", + 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, + }); + return json({ + schema_version: DECISION_ACTION_SCHEMA_VERSION, + ok: true, + replayed: true, + 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 }, + 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: operationId, + }; + } 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: operationId, + }; + } 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: operationId, + }; + } + } 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: operationId, + }; + } + + 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, + operation_id: operationId, + operation_sha256: operationSha256, + 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: [], + }); +} + +// 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 }; + 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: auditEventAction(source, row.kind), + 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()) { @@ -17726,6 +18497,7 @@ function escapeHtml(value) { } export const __test = { + auditEventAction, effectiveAccountSettings, writeCurrentStrategiesCache, readCurrentStrategiesCacheEntry,