Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ jobs:
node tests/console_binance_current_holdings_validation.mjs
node tests/console_api_worker_validation.mjs
node tests/decision_cockpit_api_validation.mjs
node tests/runtime_readback_validation.mjs
node tests/audit_pagination_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
Expand Down
8 changes: 8 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ A Cloudflare Workers-based strategy switch console. The daily pages are the Reac

**Dependency boundary:** The web app consumes generated assets (`config.js`, `strategy_profiles_asset.js`) and reads `platform-config.json` indirectly via the Worker API. It does **not** depend on Python scripts at runtime.

The cockpit's audit reader merges database keyset pages with the retained KV
activity log. Its cursor fixes the first page's time ceiling and carries the
last event's time and ID. Runtime readback projects existing lifecycle source
snapshots through an explicit account-to-target binding; it neither queries a
cloud control plane nor dispatches a runtime change. Missing and stale evidence
retain unknown effective state. See [Console API](console-api.md) for pagination,
coverage and error contracts.

---

## CI/CD: Independent Validation
Expand Down
32 changes: 32 additions & 0 deletions docs/console-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ draft saves and conflicts. All responses use `Cache-Control: no-store`.
| GET `/api/account-settings/history` | `account`, optional `platform`, `limit`, `cursor` | `SettingsHistoryResponse` / `settings_history` |
| POST `/api/account-settings/draft` | `CombinedDraftRequest` | `CombinedDraftResponse` / `draft` |
| GET `/api/runtime/today` | none | `TodayRunSummary` / `today_runs` |
| GET `/api/runtime/readback` | required `scope=platform:account-alias` | `runtime_readback` |
| GET `/api/audit-events` | optional `limit` (1–100, default 25), opaque `cursor` | `audit_events` |
| GET `/api/strategies/summary` | none | `StrategySummary` / `strategy_summary` |
| GET `/api/notifications/send-log` | `limit`, `cursor` | `NotificationSendLog` / `notification_log` |
| GET `/api/accounts/run-controls` | `account`, optional `platform` | `RunControlAvailability` / `run_controls` |
Expand All @@ -31,6 +33,36 @@ draft saves and conflicts. All responses use `Cache-Control: no-store`.
| GET `/api/performance/trades` | `scope`, `range`, optional `platform` | `TradeStatsResponse` / `trades` |

`account` and `scope` are configured account aliases, never broker account numbers.
The runtime readback route uses the explicit `platform:account-alias` pair and
does not accept `all`. It returns only saved deployment observations for the
uniquely bound runtime target. `server_time` is the response clock;
`observed_at` is the producer's deployment observation time. Missing, future,
stale, conflicting or incomplete evidence never yields a known runtime switch.
Stale observations retain their timestamp with `runtime_enabled: null` and
`scheduler_state: unknown`. Missing evidence returns `unavailable`; storage or
source discovery failures return HTTP 503. A missing scope returns 404, invalid
parameters 400, and an ambiguous configured scope 409. The endpoint makes no
external calls, dispatches no operations, and grants no trading authority.

Audit pages are ordered by observation time descending and event ID ascending.
Pass `next_cursor` unchanged; `null` ends the available traversal. The cursor
contains a versioned position and the first page's time ceiling. Newer events
cannot shift later pages. Refresh starts a new traversal. Numeric offset
cursors from the preliminary API are no longer accepted; clients must restart
from the first page after upgrading. Duplicate or unknown query parameters,
invalid cursors and out-of-range limits return 400. Unreadable sources return
503 rather than an apparently complete empty page. Both routes require an
allowed reader session and reject non-GET methods.

Audit coverage includes durable decisions, current application records, and the
existing bounded activity log. Durable records are paged at the database;
there is no fixed 1,000-record export ceiling. Application updates and activity
log retention can change what remains available between requests: this is a
read projection, not an immutable historical export. The server preserves the
`id`, `actor_alias`, structured `subject`, nullable string `revision`,
`next_cursor` and `coverage` fields for typed clients. It never includes raw
decision payloads or account configuration.

`scope=all` reuses the overview cohort: it excludes known paper accounts and
conflicting or duplicate facts. An unspecified environment remains eligible,
as in the existing charts; this does not certify a live identity. Single-account
Expand Down
45 changes: 45 additions & 0 deletions tests/audit_pagination_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { DatabaseSync } from "node:sqlite";
import { RuntimeInstances } from "../web/strategy-switch-console/worker.js";
import { auditPageQuery, auditNextCursor } from "../web/strategy-switch-console/application/console_api/audit_pagination.js";

// Exercise the production query against SQLite, including all three durable
// sources and enough rows to cross the old fixed projection ceiling.
const db = new DatabaseSync(":memory:");
try {
db.exec(`CREATE TABLE human_decision_record (kind, subject_id, material_key, action, actor, decided_at, request_key);
CREATE TABLE research_promotion_applications (ticket_id, status, updated_at, application_id);
CREATE TABLE account_setting_profile_application (platform, account_key, status, approved_by, approved_at, request_key);`);
const at = "2026-11-01T00:00:00.000Z", now = Date.parse("2026-11-01T01:00:00.000Z");
const insert = db.prepare("INSERT INTO human_decision_record VALUES ('owner', 'demo', '', 'keep_parked', 'demo', ?, ?)");
for (let index = 0; index < 1005; index++) insert.run(at, `demo-${String(index).padStart(4, "0")}`);
db.prepare("INSERT INTO research_promotion_applications VALUES ('demo', 'recorded', ?, 'demo-promotion')").run(at);
db.prepare("INSERT INTO account_setting_profile_application VALUES ('longbridge', 'demo', 'recorded', 'demo', ?, 'demo-account')").run(at);
const sql = { exec(query, ...parameters) { return { toArray: () => db.prepare(query).all(...parameters) }; } };
const read = page => RuntimeInstances.prototype.consoleReadCommand.call({ sql }, {
action: "console_audit_events", actor: "demo", limit: page.limit + 1, snapshot: page.snapshot, before: page.before,
}).rows;
let page = auditPageQuery(new URL("https://example.invalid/api/audit-events?limit=100"), now);
const seen = [];
for (let index = 0; index < 20; index++) {
const rows = read(page), current = rows.slice(0, page.limit);
seen.push(...current.map(row => row.event_id));
if (rows.length <= page.limit) break;
const last = current.at(-1);
const cursor = auditNextCursor({ occurred_at: last.occurred_at, id: last.event_id }, page);
page = auditPageQuery(new URL(`https://example.invalid/api/audit-events?limit=100&cursor=${cursor}`), now);
if (index === 0) insert.run("2026-11-01T02:00:00.000Z", "newer");
}
assert.equal(seen.length, 1007);
assert.equal(new Set(seen).size, 1007);
assert.deepEqual(seen, [...seen].sort());
assert.ok(seen.includes("research_promotion_application:demo-promotion"));
assert.ok(seen.includes("account_settings_application:demo-account"));
assert.ok(!seen.includes("durable_human_decision:newer"));
const end = auditPageQuery(new URL("https://example.invalid/api/audit-events?cursor=" + auditNextCursor({ occurred_at: at, id: seen.at(-1) }, page)), now);
assert.deepEqual(read(end), []);
for (const value of ["1", "", "bad!", btoa(JSON.stringify({ v: 9, snapshot: at, at, id: "demo" }))]) {
assert.throws(() => auditPageQuery(new URL(`https://example.invalid/api/audit-events?cursor=${value}`), now), /invalid_pagination/);
}
console.log("Audit pagination validation passed: 1007 durable rows, three sources, ties, concurrent insert, terminal cursor");
} finally { db.close(); }
49 changes: 48 additions & 1 deletion tests/decision_cockpit_api_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ assert.ok(audit.body.events.some((event) => event.source === "durable_human_deci
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.match(auditPage.body.next_cursor, /^[A-Za-z0-9_-]+$/);
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");
Expand Down Expand Up @@ -570,5 +570,52 @@ const beyondAction = await call(`/api/decisions/${encodeURIComponent(beyondId)}/
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");

// Audit pages merge the durable and bounded sources, with stable ties and no offset drift.
const kv = await mf.getKVNamespace("STRATEGY_SWITCH_CONFIG");
const auditTime = new Date(Date.now() - 60_000).toISOString();
const auditRows = Array.from({ length: 63 }, (_, index) => ({ ts: auditTime, action: `fixture_${index}`, candidate_id: `demo-${index}` }));
await kv.put("audit_log", JSON.stringify(auditRows));
const expectedAudit = await call("/api/audit-events?limit=100");
validate("audit_events", expectedAudit.body);
let page = await call("/api/audit-events?limit=7");
const seenEvents = [...page.body.events];
assert.ok(page.body.next_cursor);
// A newer event cannot shift the cursor or join an in-progress traversal.
await new Promise(resolve => setTimeout(resolve, 5));
await kv.put("audit_log", JSON.stringify([{ ts: new Date().toISOString(), action: "newer_event" }, ...auditRows]));
assert.ok((await call("/api/audit-events")).body.events.some(event => event.action === "newer_event"));
let pageCount = 1;
while (page.body.next_cursor) {
assert.ok(pageCount++ < 30, "pagination terminates");
page = await call(`/api/audit-events?limit=7&cursor=${encodeURIComponent(page.body.next_cursor)}`);
assert.equal(page.status, 200);
validate("audit_events", page.body);
seenEvents.push(...page.body.events);
}
assert.deepEqual(seenEvents.map(event => event.id), expectedAudit.body.events.map(event => event.id));
assert.equal(new Set(seenEvents.map(event => event.id)).size, seenEvents.length);
for (const query of ["limit=0", "limit=101", "limit=1.5", "limit=", "limit=1&limit=2", "cursor=", "cursor=1", "cursor=bad!", "extra=1", "cursor=a&cursor=b"]) {
assert.equal((await call(`/api/audit-events?${query}`)).status, 400);
}
assert.equal((await call("/api/audit-events", { method: "POST" })).status, 405);
await kv.put("audit_log", "{}");
assert.equal((await call("/api/audit-events")).status, 503, "invalid optional-source storage cannot look complete");
await kv.put("audit_log", "[]");

// Runtime readback is authenticated, GET-only, and does not dispatch anything.
assert.equal((await call("/api/runtime/readback?scope=longbridge:paper", { cookie: "" })).status, 401);
assert.equal((await call("/api/runtime/readback?scope=longbridge:paper", { method: "POST" })).status, 405);
for (const query of ["", "scope=", "scope=bad", "scope=longbridge:paper&scope=longbridge:paper", "scope=longbridge:paper&extra=1"]) {
assert.equal((await call(`/api/runtime/readback?${query}`)).status, 400);
}
assert.equal((await call("/api/runtime/readback?scope=longbridge:missing")).status, 404);
const missingRuntime = await call("/api/runtime/readback?scope=longbridge:paper");
assert.equal(missingRuntime.status, 200);
validate("runtime_readback", missingRuntime.body);
assert.equal(missingRuntime.body.data_status, "unavailable");
assert.equal(missingRuntime.body.runtime_enabled, null);
assert.equal(missingRuntime.body.observed_at, null);
assert.equal(missingRuntime.body.policy.execution_authority_granted, false);

await mf.dispose();
console.log("decision_cockpit_api_validation: PASS");
79 changes: 79 additions & 0 deletions tests/runtime_readback_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import worker from "./helpers/directory_worker.mjs";
import { __test } from "./helpers/directory_worker.mjs";
import { runtimeReadbackProjection } from "../web/strategy-switch-console/application/console_api/runtime_readback.js";

const now = Date.now(), observedAt = new Date(now - 1000).toISOString();
const account = { platform: "longbridge", key: "demo", runtime_status_target_id: "demo.target" };
const entry = { observed_at: observedAt,
target: { target_id: "demo.target", target: { platform: "longbridge" }, deployment: {
observed_at: observedAt, runtime_enabled: false, scheduler_state: "paused", strategy_profile: "demo_strategy",
} },
freshness: { data_status: "ready" }, deployment_freshness: { data_status: "ready" }, evidence_valid_for_seconds: 60,
};
const snapshot = { targets: [entry], errors: [] };
const project = (source = snapshot, accounts = [account]) => runtimeReadbackProjection(account, accounts, source, now);
assert.equal(project().runtime_enabled, false);
assert.equal(project().scheduler_state, "paused");
assert.equal(project().observed_at, observedAt);
assert.equal(project().data_status, "ready");
assert.equal(project(snapshot, [account, { ...account, key: "other" }]).reason, "runtime_binding_conflict");
for (const mutate of [
item => { item.freshness.data_status = "stale"; },
item => { item.observed_at = new Date(now + 1000).toISOString(); },
item => { item.deployment_freshness.data_status = "stale"; },
item => { item.target.deployment.observed_at = new Date(now - 120_000).toISOString(); },
item => { delete item.target.deployment.observed_at; },
item => { item.target.deployment.observed_at = new Date(now + 1000).toISOString(); },
item => { delete item.target.deployment; },
item => { item.target.target.platform = "ibkr"; },
item => { item.freshness.data_status = "unavailable"; },
item => { delete item.evidence_valid_for_seconds; },
]) {
const changed = structuredClone(entry); mutate(changed);
const result = project({ targets: [changed], errors: [] });
assert.notEqual(result.data_status, "ready");
assert.equal(result.runtime_enabled, null);
assert.equal(result.scheduler_state, "unknown");
}
assert.equal(project({ targets: [], errors: [] }).reason, "runtime_observation_missing");
assert.equal(project({ targets: [entry, entry], errors: [] }).reason, "runtime_binding_conflict");

// Exercise the real handler with an in-memory source and no outbound service.
const source = {
schema_version: "qsl_runtime_target_lifecycle_source_snapshot.v1", source_id: "demo",
generated_at: observedAt, computed_at: observedAt, data_status: "ready", errors: [],
targets: [{ target_id: "demo.target", target: { platform: "longbridge", configured_state: "disabled", execution_mode: "paper" },
monitoring: { runtime_guard: "pass", execution_heartbeat: "not_applicable" },
disposition: { code: "continue_disabled_validation", reason_code: "target_intentionally_disabled" },
no_order: true, deployment: { ...entry.target.deployment, execution_mode: "paper" } }],
};
const env = {
SESSION_SECRET: "synthetic-session-only", ALLOWED_GITHUB_LOGINS: "demo-reader",
STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON: JSON.stringify({ longbridge: [{ key: "demo", label: "Demo", target_name: "demo", runtime_status_target_id: "demo.target", account_selector: "DEMO", deployment_selector: "demo", account_scope: "DEMO", service_name: "demo-service", supported_domains: ["us_equity"], broker_environment: "paper", default_execution_mode: "paper" }] }),
STRATEGY_SWITCH_CONFIG: { get: async key => key.startsWith("runtime_target_lifecycle_source:") ? JSON.stringify(source) : null, put: async () => { throw Error("write attempted"); }, list: async () => ({ keys: [{ name: "runtime_target_lifecycle_source:demo" }] }) },
};
const cookie = `qsl_switch_session=${await __test.makeSession("demo-reader", [], env)}`;
const request = () => new Request("https://example.invalid/api/runtime/readback?scope=longbridge:demo", { headers: { Cookie: cookie } });
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => { throw Error("outbound attempted"); };
try {
const sourceCheck = await __test.aggregateRuntimeTargetLifecycleSources(env);
assert.deepEqual(sourceCheck.errors, [], "fixture source must validate");
let response = await worker.fetch(request(), env, {});
let body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.data_status, "ready");
assert.equal(body.runtime_enabled, false);
assert.equal(body.observed_at, observedAt);
assert.equal(JSON.stringify(body).includes("DEMO"), false);
source.targets[0].deployment.observed_at = new Date(now - 30 * 86_400_000).toISOString();
response = await worker.fetch(request(), env, {}); body = await response.json();
assert.equal(body.data_status, "stale");
assert.equal(body.runtime_enabled, null);
env.STRATEGY_SWITCH_CONFIG.get = async key => { if (key.startsWith("runtime_target_lifecycle_source:")) throw Error("read failure"); return null; };
assert.equal((await worker.fetch(request(), env, {})).status, 503);
env.STRATEGY_SWITCH_CONFIG.list = async () => { throw Error("list failure"); };
assert.equal((await worker.fetch(request(), env, {})).status, 503);
} finally { globalThis.fetch = originalFetch; }
console.log("Runtime readback validation passed");
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const instant = value => typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
&& Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
const encode = value => btoa(JSON.stringify(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

// The cursor is a position, never authority. All pages require the same session checks.
export function auditPageQuery(url, now = Date.now()) {
for (const key of url.searchParams.keys()) {
if (!["limit", "cursor"].includes(key) || url.searchParams.getAll(key).length !== 1) throw Error("invalid_pagination");
}
const raw = url.searchParams.get("limit");
if (raw !== null && !/^[1-9]\d{0,2}$/.test(raw)) throw Error("invalid_pagination");
const limit = raw === null ? 25 : Number(raw);
if (limit > 100) throw Error("invalid_pagination");
const cursor = url.searchParams.get("cursor");
if (cursor === null) return { limit, snapshot: new Date(now).toISOString(), before: null };
try {
if (!/^[A-Za-z0-9_-]{1,2048}$/.test(cursor)) throw Error();
const value = JSON.parse(atob(cursor.replace(/-/g, "+").replace(/_/g, "/")));
if (Object.keys(value).sort().join() !== "at,id,snapshot,v" || value.v !== 1
|| !instant(value.at) || !instant(value.snapshot) || value.at > value.snapshot
|| Date.parse(value.snapshot) > now || typeof value.id !== "string"
|| !/^[\x20-\x7e]{1,512}$/.test(value.id) || encode(value) !== cursor) throw Error();
return { limit, snapshot: value.snapshot, before: { at: value.at, id: value.id } };
} catch { throw Error("invalid_pagination"); }
}

export function auditEventInPage(event, page) {
return event.occurred_at <= page.snapshot && (!page.before || event.occurred_at < page.before.at
|| event.occurred_at === page.before.at && event.id > page.before.id);
}
export function auditEventOrder(left, right) {
return left.occurred_at === right.occurred_at ? (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
: left.occurred_at > right.occurred_at ? -1 : 1;
}
export function auditNextCursor(event, page) {
return encode({ v: 1, snapshot: page.snapshot, at: event.occurred_at, id: event.id });
}
Loading
Loading