UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

995 lines (917 loc) 63 kB
/** * # cli/backend/hosted — the SIM FUNNEL client (kestrel-585 / kestrel-vcn). * * A LIGHT (node+bun, fetch-only) client for the platform's hosted "one-command sim" * funnel — the three calls the `sim` verb drives, plus the proof read that carries the * graded metrics: * * - `GET /catalog` → the hosted catalog ({ entries: HostedCatalogEntry[] }), * each entry a story `title` + its `artifact_id` (the dataset the sim runs against). * - `POST /capabilities/trial` → mint an anonymous trial capability (its `capability` * field is the `Authorization: Bearer` token). * - `POST /simulate` {source, dataset:{artifact_id}} → a COMPLETED Operation directly * (artifacts + metered receipts inline), or **402 OfferResponse** at the paid boundary. * - `GET /proof/{artifact_id}` → the signed grade proof, whose `result.metrics` carry * order_count / fill_count / realized_pnl. * * This is a SEPARATE face from {@link ./remote.ts RemoteBackend} (the M1 `/sim` + SSE * contract): the funnel's `/simulate` returns the finished Operation in one shot rather * than over an SSE stream, and its `/catalog` carries marketing `title`s the M1 * `CatalogEntry` (content-addressed only) does not. The 402 body IS the M1 * {@link ./wire.ts WireOfferResponse}, so gating reuses `asOfferResponse` + the existing * `render/offer.ts` — one Offer dialect for the whole CLI. * * Slug derivation ({@link slugifyTitle}) is a pure, deterministic kebab-case of the title; * the marketing→catalog {@link SCENARIO_ALIASES} table lets the site's published slugs * resolve to the catalog's own slugs (site-is-spec). */ import type { WireOfferResponse, WireOffer, WireResumeAffordance, WireSettlementAffordance } from "./wire.ts"; import { asOfferResponse, asWireOffer, asSettlementResume, asSettlementAffordance } from "./wire.ts"; import type { Gated } from "./index.ts"; import type { Scope } from "../../protocol/index.ts"; // The published verify-set path is a WIRE constant homed in the dependency-free protocol package // (OSS-ADR-0046 §2, kestrel-s67u) — imported so this transport can never drift from the ONE path // the keys are actually served at (bead kestrel-markets-fjq7). import { VERIFY_KEYS_PATH } from "../../protocol/attestation.ts"; import type { CertifiedGradeCoveredFields, JsonValue } from "../../protocol/attestation.ts"; import type { GradeResult } from "../../protocol/index.ts"; import { CliError, EXIT } from "../errors.ts"; import type { ProblemDetails } from "../../client/index.ts"; import { idemKey } from "../../client/platform-wire.ts"; /** The default hosted funnel base — the ONE definition, owned by * {@link ../../client/platform-wire.ts platform-wire} and re-exported so the `sim` verb's * base resolution stays byte-identical to every other face (kestrel-z473.2). */ export { DEFAULT_API } from "../../client/platform-wire.ts"; /** The web origin the shareable proof URL is rendered against (never the API host). */ export const PROOF_WEB_BASE = "https://kestrel.markets"; type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; /* ─────────────────────────── hosted wire shapes ─────────────────────────── */ /** One hosted catalog entry — a curated scenario. `title` is the human name the slug is * derived from; `artifact_id` is the dataset the sim runs against. */ export interface HostedCatalogEntry { readonly artifact_id: string; readonly title: string; readonly instrument: string; readonly period: { readonly start: string; readonly end: string }; readonly free: boolean; readonly description: string; readonly frame_count: number; readonly content_hash?: string; } /** `GET /catalog` body. */ export interface HostedCatalog { readonly entries: readonly HostedCatalogEntry[]; } /** The trial capability mint (`capability` is the bearer token). */ export interface HostedTrial { readonly capability: string; readonly subject_commitment?: string; readonly expiry?: string; readonly scopes?: readonly string[]; readonly rate_limit?: { readonly requests_per_minute?: number; readonly compute_quota?: string }; } /** A metering or grade receipt on a completed Operation. */ export interface HostedReceipt { readonly kind: string; // "metered" | "grade" | … readonly node?: string; readonly amount?: number; readonly cost?: number; readonly markup?: number; readonly budget_remaining?: number; readonly root?: string; readonly signature?: string; } /** The unique signed Grade receipt guaranteed by {@link verifiedHostedOperation}. */ export interface HostedGradeReceipt extends HostedReceipt { readonly kind: "grade"; readonly root: string; readonly signature: string; } /** An artifact reference on a completed Operation (the grade carries `kind:"grade"`). */ export interface HostedArtifact { readonly artifact_id: string; readonly kind: string; readonly content_hash?: string; readonly proof_url?: string; } /** The durable artifact kinds carried by a verified Simulation evidence bundle. */ export type HostedEvidenceKind = "preflight" | "agent_call" | "session_bus" | "certified_grade" | "manifest"; /** A private, content-addressed R2 artifact reference returned by the platform. */ export interface HostedEvidenceRef { readonly artifact_id: string; readonly kind: HostedEvidenceKind; readonly content_hash: string; readonly byte_length: number; readonly classification: "private"; } /** * The minimum durable evidence for a completed Simulation. Inference-free runs have an * empty `callRefs`; model-backed runs carry one `agent_call` reference per captured call. */ export interface HostedEvidenceBundle { readonly busRef: HostedEvidenceRef; readonly gradeRef: HostedEvidenceRef; readonly callRefs: readonly HostedEvidenceRef[]; readonly manifestRef: HostedEvidenceRef; } /** `POST /simulate` wire body. Evidence stays optional here because the client validates * the untrusted response before exposing a successful Operation to callers. */ export interface HostedOperation { readonly operation_id: string; readonly intent_hash: string; readonly status: string; // "completed" | "running" | "failed" | … readonly checkpoint?: string; readonly cursor?: string; readonly artifacts: readonly HostedArtifact[]; readonly receipts: readonly HostedReceipt[]; readonly evidence?: HostedEvidenceBundle; /** Legacy/degraded responses may describe missing persistence instead of proving it. */ readonly evidence_status?: { readonly state: string; readonly detail: string }; } /** A completed Operation whose private R2 evidence bundle passed the wire validator. */ export interface VerifiedHostedOperation extends HostedOperation { readonly status: "completed"; readonly evidence: HostedEvidenceBundle; readonly evidence_status?: never; } /** * The self-declared Spend cap forwarded on `POST /simulate` as `spend.budget` (bead kestrel-4dz5). * `budget` is a finite, non-negative USD ceiling the caller OPTS INTO — the CLI never auto-pays; it * declares a cap and the platform honors it (clamping DOWN to its trial ceiling, never up). This is * the one documented trigger for the paid boundary from the CLI face. `ceiling` (optional per-turn * cap) is reserved on the wire but not yet surfaced by a CLI flag. */ export interface Spend { readonly budget: number; readonly ceiling?: number; } /** * A SPEND-BOUNDARY suspension surfaced as DATA (kestrel-e8e7, boundary UX kestrel-54j). A * bounded increment paused at the Spend boundary (`status:"suspended"`, `resume.requires_settlement`) * with a settleable {@link WireOffer} attached inline. It is a legitimate 402-class boundary — the * CLI surfaces the itemized Offer + the resume affordance and exits `5` PAYMENT_REQUIRED, never a * misleading 5xx. Distinct from the pre-drive data-access 402 ({@link WireOfferResponse}): the * suspended body carries the Offer + `resume` inline and no `settlement_methods`. */ export interface SettlementRequired { readonly operationId: string; readonly offer: WireOffer; readonly resume: WireResumeAffordance; /** * The wired settle rail this Offer advertises (bead kestrel-markets-1gr8): the EXACT * `POST /api/operations/{id}/settlement` URL an agent pays on. Present from wave-3 platform * responses; absent on a response minted before the rail was wired, in which case the caller * surfaces the resume affordance alone (never a fabricated settle URL). */ readonly settlement?: WireSettlementAffordance; } /** * The three terminal outcomes of {@link HostedFunnel.simulate}: a completed Operation * (`gated:false`), the pre-drive data-access 402 Offer (`gated:true`), or the Spend-boundary * suspension surfaced as a payment-required Offer (`settlementRequired`). The last two both * exit `5`; only the first proceeds to the proof read. */ export type HostedSimulation = | Gated<VerifiedHostedOperation> | { readonly settlementRequired: SettlementRequired }; /** `GET /proof/{id}` body — the signed grade. `result.metrics` carry the graded line. */ export interface HostedProof { readonly proof_id: string; readonly kind: "grade"; readonly result: { readonly subjectSessionId: string; readonly metrics: Record<string, number>; /** * The platform's computed explanation of WHY a run placed no orders (kestrel-kglw) — the * highest-value teaching moment of a 0-order run. Stamped by the grader on the certified * grade (gate unsatisfied / trigger never crossed / budget absent / series unavailable, …), * so the CLI SURFACES it rather than recomputing it. Optional: absent on a run that traded. */ readonly diagnostics?: { readonly never_fired_reason?: string }; }; readonly root: string; readonly signature: string; readonly verification: { readonly verified: true; readonly status: "verified"; readonly algorithm: "ed25519"; readonly kid: string; readonly epoch: number; }; readonly evView?: string; readonly issuedAt?: string; } /** * One PUBLISHED grade verify key from `/.well-known/kestrel-markets` `grade_verify_keys` * — public material only. `status:"active"` currently signs; `accept-only` is retained * through a rotation-overlap window and still verifies. A `verify` selects the key by the * proof's own `kid` and trusts BOTH statuses; a `kid` absent from this set is UNVERIFIABLE * (retired), never a false tampering verdict. */ export interface PublishedVerifyKey { readonly kid: string; readonly epoch: number; readonly algorithm: "ed25519"; readonly public_jwk: { readonly kty: string; readonly crv: string; readonly x: string }; readonly status: "active" | "accept-only"; } /** * The lean signature envelope of a PUBLISHED proof (`GET /proof/{id}`), the subset the * zero-trust {@link ../commands/verify.ts verify} verb re-checks locally: the content `root` * the signature attests over, the base64url Ed25519 `signature`, and the `kid`/`epoch` that * select the published verify key. `verificationClaim` is the proof body's OWN self-asserted * verdict — carried only so `verify` can prove it does NOT trust it (it re-verifies the * crypto independently). */ export interface PublishedProof { readonly proof_id: string; readonly root: string; readonly signature: string; readonly kid: string; readonly epoch: number; /** The proof body's OWN self-asserted `verification.verified` — for display/contrast only. */ readonly verificationClaim: boolean; /** * The signature-covered grade fields the platform published alongside the proof (OSS-ADR-0051), * when present. When the proof carries them, `verify` recomputes `certifiedRoot(coveredFields)` and * binds it to the signed `root` — catching a tampered PUBLISHED NUMBER whose old signature still * validates over the old root. ADDITIVE-OPTIONAL: absent (or malformed) reads UNKNOWN and the * recompute leg is skipped, leaving the signature check as the guarantee (never a silent false). */ readonly coveredFields?: CertifiedGradeCoveredFields; } /** * The OPEN-RECOMPUTATION evidence bundle of a PUBLISHED proof (`GET /proof/{id}/evidence`), the * durable inputs an outsider needs to re-project the Blotter LOCALLY and reproduce the hosted * certified result byte-identically (platform launch gate G10; OSS half kestrel-8kvs, platform half * kestrel-markets-tk82; ADR-0011 "OSS certification = the open re-projection computation"). * * THE CONTRACT (`kestrel.evidence-bundle/v1`) — agreed byte-for-byte with the platform half: * - `bus` — the full canonical session-Bus JSONL text (the recorded truth; the Bus is the * substrate, the Blotter its deterministic projection). The CLI folds it with the SAME published * projector (`readBusText` → `project` → `serialize`) the platform ran. * - `blotter` — the platform's PUBLISHED canonical Blotter serialization: the EXACT bytes of * `serialize(project(bus))` (deterministic machine-JSON, lexicographic keys, single trailing * newline — ADR-0011). This is the comparand `certify` byte-compares its local re-projection * against; equality is the open-recomputation determinism leg (L1 minimum). * - `engine_version` / `fill_model` — the pinned versions the run was graded under, carried so the * later judged-Grade recomputation leg (L3) can pin the same code. Optional at L1. * * The bundle carries NO signature or private material — it is the public, re-projectable input for a * FREE-TIER proof. Cross-repo determinism: same published npm package + same `bus` ⇒ byte-identical * `serialize(project(bus))` ⇒ it must equal `blotter`. */ export interface PublishedEvidenceBundle { readonly proof_id: string; readonly bus: string; readonly blotter: string; readonly engine_version?: string; readonly fill_model?: string; /** * The PUBLISHED grade numbers (OSS-ADR-0051), when the bundle carries them: `certify` grades the * re-projected tape (`grade(reproject(bus))`) and binds the result to these numbers — closing the * numbers↔tape leg. ADDITIVE-OPTIONAL (absent → the leg reads UNKNOWN, never a fabricated pass). */ readonly publishedGrade?: GradeResult; /** * The signature-covered grade fields (OSS-ADR-0051), when present: `certify` recomputes * `certifiedRoot(coveredFields)` and binds it to {@link root} — closing the numbers↔root leg. A * tampered published number breaks this recompute (RED). ADDITIVE-OPTIONAL. */ readonly coveredFields?: CertifiedGradeCoveredFields; /** The published signed grade `root` (with its `sha256:` display prefix), when present — the comparand * the `certifiedRoot(coveredFields)` recompute binds to. ADDITIVE-OPTIONAL. */ readonly root?: string; } /* ───────────────────────── slug derivation + aliases ────────────────────── */ /** * Deterministic, stable kebab-case of a scenario title: lowercase, fold accents, collapse * every run of non-alphanumerics to a single `-`, trim leading/trailing `-`. Pure — the * same title always slugs to the same value across releases (the shareable-command * property). `"Meme-stock short squeeze"` → `"meme-stock-short-squeeze"`. */ export function slugifyTitle(title: string): string { return title .normalize("NFKD") .replace(/[̀-ͯ]/g, "") // strip combining marks .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } /** * Marketing → catalog slug aliases (site-is-spec). Public copy is an executable contract, * even when its memorable slug differs from the catalog title's deterministic slug. These * explicit aliases preserve the byte-for-byte public command while the result still names * the canonical catalog slug. Additive: a new marketing name never changes an existing * catalog slug, and an unknown name still fails closed rather than selecting a default. */ export const SCENARIO_ALIASES: Readonly<Record<string, string>> = { "wsb-gme-meme-short-squeeze": "meme-stock-short-squeeze", "wsb-gme-short-squeeze": "meme-stock-short-squeeze", // The site's scenario-card slugs (kestrel-585: the cards are the acceptance tests — // every card's `sim <slug>` must run). Cards whose slug already equals the catalog's // own (fomc-rate-decision-whipsaw, mean-reversion-range-fade, multi-day-trend-swing, // small-cap-spike-and-fade) need no row here. "covid-crash-march-2020": "pandemic-volatility-crash", "chip-earnings-blowout": "ai-chip-earnings-blowout", "intraday-opening-drive": "intraday-opening-drive-momentum", }; /** A slug resolved against the catalog: the matched entry + the canonical slug + whether * an alias was used to reach it. */ export interface ResolvedScenario { readonly entry: HostedCatalogEntry; readonly canonicalSlug: string; readonly requestedSlug: string; readonly viaAlias: boolean; } /** * Resolve a lowercase scenario slug against the catalog (exact slug of the title, after * alias expansion). Returns `null` when no title slugs to it — the caller turns that into * a loud NOT_FOUND with near-misses, never a silent default. */ export function resolveScenarioSlug( slug: string, entries: readonly HostedCatalogEntry[], ): ResolvedScenario | null { const canonical = SCENARIO_ALIASES[slug] ?? slug; const viaAlias = canonical !== slug; for (const entry of entries) { if (slugifyTitle(entry.title) === canonical) { return { entry, canonicalSlug: canonical, requestedSlug: slug, viaAlias }; } } return null; } /** The scenario slug of every catalog entry, in catalog order (menu + near-miss source). */ export function catalogSlugs(entries: readonly HostedCatalogEntry[]): string[] { return entries.map((e) => slugifyTitle(e.title)); } /** * Up to `limit` near-miss slugs for an unknown input, ranked deterministically by * shared hyphen-token count, then common-prefix length, then alphabetically. A pure * suggestion helper for the NOT_FOUND hint — never throws. */ export function nearMissSlugs( input: string, entries: readonly HostedCatalogEntry[], limit = 3, ): string[] { const inputTokens = new Set(input.split("-").filter(Boolean)); const scored = catalogSlugs(entries).map((slug) => { const shared = slug.split("-").filter((t) => inputTokens.has(t)).length; let prefix = 0; while (prefix < slug.length && prefix < input.length && slug[prefix] === input[prefix]) prefix += 1; return { slug, shared, prefix }; }); scored.sort((a, b) => b.shared - a.shared || b.prefix - a.prefix || (a.slug < b.slug ? -1 : 1)); return scored.slice(0, limit).map((s) => s.slug); } /* ──────────────────────────── the funnel client ────────────────────────── */ export interface HostedFunnelOptions { readonly baseUrl: string; readonly fetch?: FetchLike; } /** The hosted sim-funnel HTTP client. Fetch-injectable (tests pass an in-process face). */ export class HostedFunnel { private readonly base: string; private readonly fetch: FetchLike; constructor(opts: HostedFunnelOptions) { this.base = opts.baseUrl.replace(/\/+$/, ""); const f = opts.fetch ?? (globalThis.fetch as FetchLike | undefined); if (f === undefined) throw new CliError({ code: "RUNTIME_UNAVAILABLE", exit: EXIT.RUNTIME_UNAVAILABLE, message: "global fetch unavailable — need node ≥18 or bun for `kestrel sim`", }); this.fetch = f; } /** * The absolute URL of the PUBLISHED verify-set (`/.well-known/kestrel-markets` `grade_verify_keys`), * resolved at this client's API base. `verify` names it on a non-VERIFIED verdict so a skeptical * reader can independently re-adjudicate the signature against the same key material, instead of * guessing where the key is published (bead kestrel-markets-5bjn/fjq7). */ get verifyKeysUrl(): string { return `${this.base}${VERIFY_KEYS_PATH}`; } /** `GET /catalog` — the hosted catalog (public, no auth). */ async catalog(): Promise<HostedCatalog> { const res = await this.fetch(`${this.base}/catalog`, { headers: { accept: "application/json" } }); if (!res.ok) throw httpErr(res.status, "GET /catalog failed"); const body = (await res.json()) as HostedCatalog | HostedCatalogEntry[]; // Accept both `{entries:[…]}` (canonical) and a bare array, fail-closed on anything else. const entries = Array.isArray(body) ? body : body?.entries; if (!Array.isArray(entries)) throw httpErr(502, "GET /catalog returned no entries[]"); return { entries: verifiedCatalogEntries(entries) }; } /** * `POST /capabilities/trial` — mint an anonymous trial capability. * * `opts.copyToken`, when present, is forwarded VERBATIM as the single opaque header * `X-Kestrel-Copy-Token` — exactly like the `Idempotency-Key` this client already * forwards. The OSS CLI never parses, interprets, or documents its meaning: a * copy-token is an opaque pass-through nonce whose attribution semantics live entirely * in the managed platform (growth-wave0 §n04e.1 repo-boundary ruling). Absent → the * header is omitted and behaviour is unchanged. */ async mintTrial(opts: { readonly copyToken?: string } = {}): Promise<HostedTrial> { const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json" }; if (opts.copyToken !== undefined && opts.copyToken !== "") headers["x-kestrel-copy-token"] = opts.copyToken; const res = await this.fetch(`${this.base}/capabilities/trial`, { method: "POST", headers, body: JSON.stringify({}), }); if (!res.ok) throw httpErr(res.status, "trial capability mint failed"); return verifiedHostedTrial(await safeJson(res)); } /** * `GET /.well-known/kestrel-markets` → the PUBLISHED grade verify-set (`grade_verify_keys`): * the public Ed25519 halves (`{kid, epoch, algorithm, public_jwk, status}`) an anonymous * third party uses to INDEPENDENTLY re-verify a certified grade's signature. This is the * zero-trust verification input for the `verify` verb — the CLI fetches these keys itself * and never trusts a proof body's own `verification.verified` self-assertion. */ async verifyKeys(opts: { readonly copyToken?: string } = {}): Promise<PublishedVerifyKey[]> { const headers: Record<string, string> = { accept: "application/json" }; if (opts.copyToken !== undefined && opts.copyToken !== "") headers["x-kestrel-copy-token"] = opts.copyToken; const res = await this.fetch(`${this.base}${VERIFY_KEYS_PATH}`, { headers }); if (!res.ok) throw httpErr(res.status, `GET ${VERIFY_KEYS_PATH} failed`); return verifiedVerifyKeys(await safeJson(res)); } /** * `GET /proof/{id}` — fetch a PUBLISHED proof's signature envelope for independent * verification. Unlike {@link proof}, this takes NO Operation/receipt to cross-link (the * caller is verifying a proof it did not necessarily produce), so it validates only the * lean shape the local signature check needs: `root`, `signature`, and the `verification` * key selector (`kid`/`epoch`). A 404 is surfaced as `null` (NOT_FOUND is a verdict, not a * crash). The `copyToken` is forwarded opaquely on the read, if supplied. */ async publishedProof(id: string, opts: { readonly copyToken?: string } = {}): Promise<PublishedProof | null> { const headers: Record<string, string> = { accept: "application/json" }; if (opts.copyToken !== undefined && opts.copyToken !== "") headers["x-kestrel-copy-token"] = opts.copyToken; const res = await this.fetch(`${this.base}/proof/${encodeURIComponent(id)}`, { headers }); if (res.status === 404) return null; if (!res.ok) throw httpErr(res.status, `GET /proof/${id} failed`); return verifiedPublishedProof(await safeJson(res), id); } /** * `GET /proof/{id}/evidence` — fetch a PUBLISHED proof's OPEN-RECOMPUTATION evidence bundle * (the {@link PublishedEvidenceBundle} the `certify` verb re-projects locally, gate G10). A 404 * is surfaced as `null` (a proof with no re-projectable free-tier bundle is a verdict, not a * crash — e.g. a paid episode or a retention-expired artifact). The untrusted body is validated * before it is handed back: a 2xx is not proof of a usable bundle. The `copyToken` is forwarded * opaquely, exactly like the other reads. */ async evidenceBundle(id: string, opts: { readonly copyToken?: string } = {}): Promise<PublishedEvidenceBundle | null> { const headers: Record<string, string> = { accept: "application/json" }; if (opts.copyToken !== undefined && opts.copyToken !== "") headers["x-kestrel-copy-token"] = opts.copyToken; const res = await this.fetch(`${this.base}/proof/${encodeURIComponent(id)}/evidence`, { headers }); if (res.status === 404) return null; if (!res.ok) throw httpErr(res.status, `GET /proof/${id}/evidence failed`); return verifiedEvidenceBundle(await safeJson(res), id); } /** * `POST /simulate` — run the scenario under `source`. Returns the COMPLETED Operation * directly (gated:false), the structured pre-drive 402 Offer as DATA (gated:true), or a * Spend-boundary suspension surfaced as a payment-required Offer (settlementRequired) — * never a browser redirect, and never a misleading 5xx at a legitimate settlement boundary. */ async simulate( req: { readonly source: string; readonly artifactId: string; readonly spend?: Spend }, bearer: string, ): Promise<HostedSimulation> { // The caller's self-declared Spend cap (`--budget`, bead kestrel-4dz5) rides `spend.budget` // — the ONLY documented 402 trigger on POST /simulate. Included ONLY when declared, so the // free default path is byte-identical to before. The platform HONORS a declared budget as a // cap (estimate≡meter; it clamps DOWN to its trial ceiling, never up): a tiny budget on a // metered dataset pauses the increment at the Spend boundary and returns the settleable Offer. const body = { source: req.source, dataset: { artifact_id: req.artifactId }, ...(req.spend !== undefined ? { spend: req.spend } : {}), }; const encoded = JSON.stringify(body); const idempotencyKey = idemKey("POST", "/simulate", body); let pendingOperationId: string | undefined; for (let attempt = 0; attempt < 2; attempt += 1) { const res = await this.fetch(`${this.base}/simulate`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${bearer}`, "idempotency-key": idempotencyKey, }, body: encoded, }); if (res.status === 402) { if (pendingOperationId !== undefined) throw evidenceError("evidence-finalize replay crossed an unexpected payment boundary"); const offer = asOfferResponse(await safeJson(res)); if (offer === null) throw httpErr(402, "402 body was not a structured OfferResponse"); return { gated: true, payment: offer }; } if (!res.ok) throw await httpErrFrom(res, "POST /simulate failed"); const value = await safeJson(res); // The SPEND BOUNDARY (kestrel-e8e7): the increment paused at `status:"suspended"` with a // settleable Offer + a `requires_settlement` resume inline (HTTP 201, not 402). It is a // legitimate 402-class boundary, so surface the Offer as DATA (exit 5), NOT the evidence // verifier's "operation is not completed" 502. A suspension WITHOUT a settleable Offer // (the bare-id fallback / a genuinely incomplete op) is NOT one and falls through to the // fail-closed evidence check below — this never blanket-maps every suspension to a 402. const settlement = settlementRequiredOperation(value); if (settlement !== null) { if (pendingOperationId !== undefined) throw evidenceError("evidence-finalize replay crossed an unexpected settlement boundary"); return { settlementRequired: settlement }; } const pending = evidencePendingOperation(value); if (pending !== null) { if (pendingOperationId !== undefined && pending.operation_id !== pendingOperationId) throw evidenceError("evidence-finalize replay changed Operation identity"); pendingOperationId = pending.operation_id; if (attempt === 1) throw evidenceError( `R2 evidence remained pending after one effect-once finalize-only retry: ${pending.evidence_status.detail}`, ); continue; } const op = verifiedHostedOperation(value); if (pendingOperationId !== undefined && op.operation_id !== pendingOperationId) throw evidenceError("evidence-finalize replay changed Operation identity"); return { gated: false, value: op }; } throw evidenceError("R2 evidence finalize retry exhausted"); } /** `GET /proof/{artifact_id}` — require a re-verified Grade bound to this Operation. */ async proof( artifact: HostedArtifact, receipt: HostedGradeReceipt, operationId: string, bearer?: string, ): Promise<HostedProof> { const headers: Record<string, string> = { accept: "application/json" }; if (bearer !== undefined) headers["authorization"] = `Bearer ${bearer}`; const res = await this.fetch(`${this.base}/proof/${encodeURIComponent(artifact.artifact_id)}`, { headers }); if (!res.ok) throw httpErr(res.status, `GET /proof/${artifact.artifact_id} failed`); return verifiedHostedProof(await safeJson(res), artifact, receipt, operationId); } } /* ─────────────────────── receipt / metric extraction ───────────────────── */ /** The metered-receipt summary the renderer prints: count + the last budget_remaining. */ export interface ReceiptsSummary { readonly meteredCount: number; readonly budgetRemaining: number | null; } /** Summarize an Operation's receipts: how many metered draws, and the budget left after * the last one (`null` when no metered receipt carried a budget). */ export function summarizeReceipts(receipts: readonly HostedReceipt[]): ReceiptsSummary { let meteredCount = 0; let budgetRemaining: number | null = null; for (const r of receipts) { if (r.kind === "metered") { meteredCount += 1; if (typeof r.budget_remaining === "number") budgetRemaining = r.budget_remaining; } } return { meteredCount, budgetRemaining }; } /** The graded subset the renderer shows, read from the proof's `result.metrics`. */ export interface GradedMetrics { readonly orderCount: number; readonly fillCount: number; readonly realizedPnl: number; } /** * Read the grader's never-fired reason off a signed proof (kestrel-kglw): the certified explanation * of WHY a run placed no orders, so the CLI SURFACES it (never recomputes it). Returns the reason * string only when the proof carries a non-empty one; `undefined` otherwise (a run that traded, a * still-settling proof, or a platform that stamped none) — so a caller can gate its 0-order line on it. */ export function neverFiredReasonOf(proof: HostedProof | null): string | undefined { const reason = proof?.result.diagnostics?.never_fired_reason; return typeof reason === "string" && reason.trim().length > 0 ? reason : undefined; } /** Read `order_count` / `fill_count` / `realized_pnl` from a proof's metrics (0 when absent). */ export function gradedMetrics(metrics: Record<string, number> | undefined): GradedMetrics { const m = metrics ?? {}; return { orderCount: numOr0(m["order_count"]), fillCount: numOr0(m["fill_count"]), realizedPnl: numOr0(m["realized_pnl"]), }; } /** The unique Grade artifact guaranteed by {@link verifiedHostedOperation}. */ export function gradeArtifactOf(op: VerifiedHostedOperation): HostedArtifact { return op.artifacts.find((artifact) => artifact.kind === "grade")!; } /** The unique Grade receipt guaranteed by {@link verifiedHostedOperation}. */ export function gradeReceiptOf(op: VerifiedHostedOperation): HostedGradeReceipt { return op.receipts.find((receipt) => receipt.kind === "grade")! as HostedGradeReceipt; } /** The shareable web proof URL for a grade artifact (`kestrel.markets/proof/<id>`). */ export function proofWebUrl(artifactId: string): string { return `${PROOF_WEB_BASE}/proof/${artifactId}`; } /* ──────────────────────────────── helpers ──────────────────────────────── */ function numOr0(v: unknown): number { return typeof v === "number" && Number.isFinite(v) ? v : 0; } const ARTIFACT_ID_RE = /^art_[0-9a-f]{24}$/; const CONTENT_HASH_RE = /^sha256:[0-9a-f]{64}$/; const OPERATION_ID_RE = /^op_[A-Za-z0-9][A-Za-z0-9_-]*$/; function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } function evidenceError(detail: string): CliError { return httpErr(502, `POST /simulate did not return verified R2 evidence: ${detail}`); } function operationError(detail: string): CliError { return httpErr(502, `POST /simulate returned no verified public Grade artifact: ${detail}`); } function catalogError(detail: string): CliError { return httpErr(502, `GET /catalog returned invalid curated scenarios: ${detail}`); } function nonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } /** Validate the untrusted curated catalog before any slug can select a dataset. */ function verifiedCatalogEntries(values: readonly unknown[]): HostedCatalogEntry[] { const entries = values.map((value, index) => { if (!isRecord(value)) throw catalogError(`entries[${index}] is not an object`); if (!nonEmptyString(value["artifact_id"])) throw catalogError(`entries[${index}].artifact_id is empty`); if (!nonEmptyString(value["title"])) throw catalogError(`entries[${index}].title is empty`); if (slugifyTitle(value["title"]).length === 0) throw catalogError(`entries[${index}].title does not produce a canonical slug`); if (!nonEmptyString(value["instrument"])) throw catalogError(`entries[${index}].instrument is empty`); const period = value["period"]; if (!isRecord(period) || !nonEmptyString(period["start"]) || !nonEmptyString(period["end"])) throw catalogError(`entries[${index}].period must name start and end`); if (typeof value["free"] !== "boolean") throw catalogError(`entries[${index}].free must be boolean`); if (typeof value["description"] !== "string") throw catalogError(`entries[${index}].description must be string`); if (!Number.isSafeInteger(value["frame_count"]) || (value["frame_count"] as number) <= 0) throw catalogError(`entries[${index}].frame_count must be a positive integer`); if (value["content_hash"] !== undefined && typeof value["content_hash"] !== "string") throw catalogError(`entries[${index}].content_hash must be string when present`); return value as unknown as HostedCatalogEntry; }); const slugs = new Set<string>(); const artifacts = new Set<string>(); for (const entry of entries) { const slug = slugifyTitle(entry.title); if (slugs.has(slug)) throw catalogError(`duplicate catalog canonical slug ${JSON.stringify(slug)}`); if (artifacts.has(entry.artifact_id)) throw catalogError(`duplicate catalog artifact id ${JSON.stringify(entry.artifact_id)}`); slugs.add(slug); artifacts.add(entry.artifact_id); } return entries; } function trialError(detail: string): CliError { return httpErr(502, `POST /capabilities/trial returned an unusable trial capability: ${detail}`); } /** * The Simulation scopes the funnel's trial bearer actually exercises: `sim` authorizes * POST /simulate, `grade` authorizes GET /proof. `data` is deliberately NOT required — * /catalog is public (no auth), so demanding it would reject a correctly-narrowed trial. */ const REQUIRED_TRIAL_SCOPES = ["sim", "grade"] as const satisfies readonly Scope[]; /** * Scopes an anonymous trial may never carry (see {@link ../../protocol/index.ts TrialCapability}: * a trial excludes commerce, paper, broker, wallet and live; ADR-0034 keeps `broker`/`live` * behind the human-gated live boundary). One of these on a trial mint means the platform * handed back something that is not a trial — refuse it rather than spend it. */ const FORBIDDEN_TRIAL_SCOPES = ["paper", "broker", "live"] as const satisfies readonly Scope[]; /** * Validate the untrusted trial mint before its token is spent at POST /simulate. A 2xx is not * proof of a usable trial: the bearer must be a real token, and any ADVERTISED `scopes` are a * claim about what that bearer may do, so they are held to the trial boundary — every * Simulation scope the funnel exercises present, no authority scope a trial may never hold. * `scopes` is optional on the wire and an absent list asserts nothing, so it is not an * authority claim to screen; unknown scopes pass, because the vocabulary is extensible * (protocol/index.ts) and only authority is named here. */ function verifiedHostedTrial(value: unknown): HostedTrial { if (!isRecord(value)) throw trialError("body is missing"); if (!nonEmptyString(value["capability"])) throw trialError("capability token is empty"); const scopes = value["scopes"]; if (scopes === undefined) return value as unknown as HostedTrial; if (!Array.isArray(scopes) || !scopes.every((s) => typeof s === "string")) throw trialError("scopes must be an array of strings when advertised"); for (const required of REQUIRED_TRIAL_SCOPES) if (!scopes.includes(required)) throw trialError(`advertised scopes lack the required Simulation scope ${JSON.stringify(required)}`); for (const forbidden of FORBIDDEN_TRIAL_SCOPES) if (scopes.includes(forbidden)) throw trialError(`advertised scopes carry the forbidden authority scope ${JSON.stringify(forbidden)}`); return value as unknown as HostedTrial; } function proofError(detail: string): CliError { return httpErr(502, `GET /proof returned no verified Grade proof: ${detail}`); } interface EvidencePendingOperation { readonly operation_id: string; readonly status: "running"; readonly evidence_status: { readonly state: "pending"; readonly detail: string }; readonly offer: null; readonly resume: { readonly method: "POST"; readonly path: "/api/simulate"; readonly operation_id: string; readonly cursor: string; readonly requires_settlement: false; readonly detail: string; }; } /** Only this exact non-terminal state is safe to replay: ticks/calls are already captured and * the platform promises the identical request retries private R2 finalization only. */ function evidencePendingOperation(value: unknown): EvidencePendingOperation | null { if (!isRecord(value) || value["status"] !== "running" || !OPERATION_ID_RE.test(String(value["operation_id"] ?? ""))) return null; const status = value["evidence_status"]; if (!isRecord(status) || status["state"] !== "pending" || typeof status["detail"] !== "string") return null; if (!Array.isArray(value["artifacts"]) || value["artifacts"].length !== 0) return null; if (!Array.isArray(value["receipts"]) || value["evidence"] !== undefined) return null; if (value["offer"] !== null) return null; const resume = value["resume"]; if ( !isRecord(resume) || resume["method"] !== "POST" || resume["path"] !== "/api/simulate" || resume["operation_id"] !== value["operation_id"] || typeof value["cursor"] !== "string" || resume["cursor"] !== value["cursor"] || resume["requires_settlement"] !== false || typeof resume["detail"] !== "string" ) return null; return value as unknown as EvidencePendingOperation; } /** * Recognize the SPEND-BOUNDARY suspension EXACTLY (kestrel-e8e7): `status:"suspended"`, a * well-formed operation id, a settleable inline {@link WireOffer} bound to it, and a * settlement-gated `resume` (canonical `POST /api/simulate`, `requires_settlement:true`) whose * `cursor` matches the body's. Anything short of all four — a bare `{offer_id, operation_id}` * fallback, a null offer, a non-canonical resume — returns `null`, so the caller's existing * fail-closed evidence path handles it (a genuinely incomplete op stays a typed failure). */ function settlementRequiredOperation(value: unknown): SettlementRequired | null { if (!isRecord(value) || value["status"] !== "suspended") return null; const opId = value["operation_id"]; if (typeof opId !== "string" || !OPERATION_ID_RE.test(opId)) return null; const offer = asWireOffer(value["offer"]); if (offer === null || offer.operation_id !== opId) return null; const resume = asSettlementResume(value["resume"], opId); if (resume === null) return null; if (typeof value["cursor"] === "string" && resume.cursor !== value["cursor"]) return null; // The wired settle rail (bead kestrel-markets-1gr8) rides a top-level `settlement` affordance // on wave-3 responses. It is ADDITIVE — a response minted before the rail existed carries none, // so a null decode leaves the boundary detection intact and the render falls back to `resume`. const settlement = asSettlementAffordance(value["settlement"]); return { operationId: opId, offer, resume, ...(settlement !== null ? { settlement } : {}) }; } function gradeArtifact(value: unknown): HostedArtifact { if (!isRecord(value) || value["kind"] !== "grade") throw operationError("kind must be grade"); if (typeof value["artifact_id"] !== "string" || !ARTIFACT_ID_RE.test(value["artifact_id"])) throw operationError("artifact_id is malformed"); if (typeof value["content_hash"] !== "string" || !CONTENT_HASH_RE.test(value["content_hash"])) throw operationError("content_hash is malformed"); if (value["proof_url"] !== undefined && value["proof_url"] !== `/proof/${value["artifact_id"]}`) throw operationError("proof_url does not name the Grade artifact"); return value as unknown as HostedArtifact; } function gradeReceipt(value: unknown, artifact: HostedArtifact): HostedGradeReceipt { if (!isRecord(value) || value["kind"] !== "grade") throw operationError("Grade receipt kind must be grade"); if (value["root"] !== artifact.content_hash) throw operationError("Grade receipt root does not match the Grade artifact"); if (typeof value["signature"] !== "string" || value["signature"].length === 0) throw operationError("Grade receipt signature is missing"); return value as unknown as HostedGradeReceipt; } /** Validate one untrusted private R2 reference, including its expected semantic kind. */ function evidenceRef(value: unknown, expectedKind: HostedEvidenceKind, field: string): HostedEvidenceRef { if (!isRecord(value)) throw evidenceError(`${field} is missing`); if (value["kind"] !== expectedKind) throw evidenceError(`${field}.kind must be ${expectedKind}`); if (typeof value["artifact_id"] !== "string" || !ARTIFACT_ID_RE.test(value["artifact_id"])) throw evidenceError(`${field}.artifact_id is malformed`); if (typeof value["content_hash"] !== "string" || !CONTENT_HASH_RE.test(value["content_hash"])) throw evidenceError(`${field}.content_hash is malformed`); if (!Number.isSafeInteger(value["byte_length"]) || (value["byte_length"] as number) <= 0) throw evidenceError(`${field}.byte_length must be a positive integer`); if (value["classification"] !== "private") throw evidenceError(`${field}.classification must be private`); return value as unknown as HostedEvidenceRef; } /** * Turn the untrusted success body into the only Operation shape callers may treat as a * successful Simulation. A 2xx response is insufficient: completion and all durable R2 * artifacts must be independently well-shaped, and a degraded `evidence_status` is never * accepted as a substitute for those references. */ export function verifiedHostedOperation(value: unknown): VerifiedHostedOperation { if (!isRecord(value) || !OPERATION_ID_RE.test(String(value["operation_id"] ?? ""))) throw httpErr(502, "POST /simulate returned malformed Operation id"); if (value["status"] !== "completed") throw evidenceError("operation is not completed"); if ("evidence_status" in value) throw evidenceError("legacy evidence_status is not durable proof"); const raw = value["evidence"]; if (!isRecord(raw)) throw evidenceError("bundle is missing"); const calls = raw["callRefs"]; if (!Array.isArray(calls)) throw evidenceError("callRefs must be an array"); if (calls.length !== 0) throw evidenceError(`inference-free sim unexpectedly captured ${calls.length} model call(s)`); const artifacts = value["artifacts"]; if (!Array.isArray(artifacts)) throw operationError("artifacts must be an array"); const grades = artifacts.filter((artifact) => isRecord(artifact) && artifact["kind"] === "grade"); if (grades.length !== 1) throw operationError(`expected exactly one Grade artifact, received ${grades.length}`); const grade = gradeArtifact(grades[0]); if (!Array.isArray(value["receipts"])) throw operationError("receipts must be an array"); const gradeReceipts = value["receipts"].filter((receipt) => isRecord(receipt) && receipt["kind"] === "grade"); if (gradeReceipts.length !== 1) throw operationError(`expected exactly one Grade receipt, received ${gradeReceipts.length}`); gradeReceipt(gradeReceipts[0], grade); const evidence: HostedEvidenceBundle = { busRef: evidenceRef(raw["busRef"], "session_bus", "busRef"), gradeRef: evidenceRef(raw["gradeRef"], "certified_grade", "gradeRef"), callRefs: calls.map((ref, index) => evidenceRef(ref, "agent_call", `callRefs[${index}]`)), manifestRef: evidenceRef(raw["manifestRef"], "manifest", "manifestRef"), }; return { ...(value as unknown as HostedOperation), status: "completed", evidence } as VerifiedHostedOperation; } /** Validate the public Grade projection and cross-link it to its Operation artifact. */ export function verifiedHostedProof( value: unknown, artifact: HostedArtifact, receipt: HostedGradeReceipt, operationId: string, ): HostedProof { if (!isRecord(value)) throw proofError("body is missing"); if (value["proof_id"] !== artifact.artifact_id) throw proofError("proof_id does not match the Grade artifact"); if (value["kind"] !== "grade") throw proofError("kind must be grade"); if (value["root"] !== artifact.content_hash) throw proofError("root does not match the Grade artifact"); if (value["signature"] !== receipt.signature) throw proofError("signature does not match the Grade receipt"); const verification = value["verification"]; if ( !isRecord(verification) || verification["verified"] !== true || verification["status"] !== "verified" || verification["algorithm"] !== "ed25519" || typeof verification["kid"] !== "string" || !Number.isSafeInteger(verification["epoch"]) ) throw proofError("signature did not re-verify"); const result = value["result"]; if (!isRecord(result) || result["subjectSessionId"] !== operationId) throw proofError("subjectSessionId does not match the Operation"); const metrics = result["metrics"]; if (!isRecord(metrics)) throw proofError("metrics are missing"); for (const name of ["order_count", "fill_count"] as const) { if (!Number.isSafeInteger(metrics[name]) || (metrics[name] as number) < 0) throw proofError(`metric ${name} must be a nonnegative integer`); } if (typeof metrics["realized_pnl"] !== "number" || !Number.isFinite(metrics["realized_pnl"])) throw proofError("metric realized_pnl is missing or non-finite"); return value as unknown as HostedProof; } function verifyKeysError(detail: string): CliError { return httpErr(502, `GET ${VERIFY_KEYS_PATH} returned an unusable verify-set: ${detail}`); } /** * Validate the untrusted `grade_verify_keys` set before a key can be trusted to verify a * proof. Each entry must name a `kid`, the ed25519 algorithm, a well-formed OKP/Ed25519 * public JWK (with the base64url `x` scalar), and a known `status`. An entry that is * malformed is dropped fail-closed (a broken key can never MINT a false `verified`), never * a throw that would deny an otherwise-usable set; a totally malformed body throws. */ export function verifiedVerifyKeys(value: unknown): PublishedVerifyKey[] { const set = isRecord(value) ? value["grade_verify_keys"] : undefined; if (!Array.isArray(set)) throw verifyKeysError("grade_verify_keys is not an array"); const out: PublishedVerifyKey[] = []; for (const k of set) { if (!isRecord(k)) continue; if (!nonEmptyString(k["kid"]) || k["algorithm"] !== "ed25519") continue; if (k["status"] !== "active" && k["status"] !== "accept-only") continue; const jwk = k["public_jwk"]; if (!isRecord(jwk) || jwk["kty"] !== "OKP" || jwk["crv"] !== "Ed25519" || !nonEmptyString(jwk["x"])) continue; if (!Number.isSafeInteger(k["epoch"])) continue; out.push({ kid: k["kid"], epoch: k["epoch"] as number, algorithm: "ed25519", public_jwk: { kty: "OKP", crv: "Ed25519", x: jwk["x"] as string }, status: k["status"] as "active" | "accept-only", }); } return out; } function publishedProofError(detail: string): CliError { return httpErr(502, `GET /proof returned an unverifiable proof body: ${detail}`); } /** * Decode the OPTIONAL signature-covered grade fields a published proof may carry (OSS-ADR-0051), so * `verify` can recompute `certifiedRoot(coveredFields)` and bind the published NUMBERS to the signed * `root`. FAIL-CLOSED / additive-optional: a wholly absent block, or one whose required members are * missing or ill-typed, returns `undefined` — the recompute leg reads UNKNOWN and is skipped, never a * fabricated pass. Only a WELL-FORMED covered-field set is admitted as a recompute basis; a present * block that decodes must carry every required member with the right shape. The structure