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
text/typescript
/**
* # 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