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.

342 lines (312 loc) 15.7 kB
/** * # protocol/wire — the PLATFORM HTTP/SSE wire shapes (kestrel-5rb, OSS-ADR-0046). * * The single, authoritative TypeScript mirror of the kestrel.markets contract as * served on the wire: **snake_case** bodies + the canonical SSE `OperationEvent.type` * vocabulary, transcribed from * `kestrel.markets/packages/contracts/openapi/kestrel-markets.openapi.json` * (schemas TrialCapability / ValidateResult / Operation / OfferResponse / Offer / * Money / SettlementMethod / HumanAction / Proof / OperationEvent; the schema is * authoritative for shapes, docs/contract/http-sse-contract.md for semantics). * * Per OSS-ADR-0046 these shapes live HERE in `src/protocol` — the same home as the * rest of the wire vocabulary (the `Scope` union, `RECEIPT_KINDS`, the SSE * constants) — so a shape change is one edit and drift is a red conformance run. * The CLI transport (`src/cli/backend/wire.ts`) re-exports this module rather than * hand-maintaining a parallel copy. * * This is DELIBERATELY distinct from the OSS camelCase domain types in * {@link ./index.ts}: the wire is snake_case, the Offer amount is a {@link WireMoney} * object (not a bare number), the cursor is an OPAQUE STRING (not `{sequence, token}`), * and the SSE events are `operation.*`. Both {@link ../cli/backend/remote.ts remote.ts} * (the client) and {@link ../cli/backend/mock.ts mock.ts} (the truthful in-process * server) consume it through the transport re-export, so neither can silently * re-fabricate a dialect: the SSE vocabulary in particular has exactly one definition * here, drift-guarded against the OpenAPI enum by `tests/cli.remote-conformance.test.ts`. * * Pure types + constants + pure decoders. ZERO runtime dependencies — the only import * is the intra-protocol domain types it decodes into (asserted by * tests/protocol.boundary.test.ts). */ import type { Scope, TrialCapability, Operation, OperationState } from "./index.ts"; /** * The OVERSIGHT wire mirror (contract §4.4, kestrel-telx.5) — `WireOversightEvent` + the * snake_case decoders, re-exported HERE so it sits beside {@link SSE_EVENT_TYPES} on the ONE * import path the CLI transport ({@link ../cli/backend/wire.ts}) re-exports. The definitions live * in their own leaf only because they are long; nothing there re-declares an SSE vocabulary (the * closed oversight enum has exactly one home, `./oversight.ts`). */ export * from "./oversight-wire.ts"; /* ───────────────────────────── SSE vocabulary ──────────────────────────── */ /** The canonical `OperationEvent.type` enum (openapi OperationEvent.type). The ONE * definition of the SSE vocabulary; the conformance suite pins it to the fixture. */ export const SSE_EVENT_TYPES = [ "operation.started", "operation.progress", "operation.artifact", "operation.receipt", "operation.suspended", "operation.resumed", "operation.completed", "operation.failed", ] as const; export type SseEventType = (typeof SSE_EVENT_TYPES)[number]; /** The two terminal event types that end an Operation's stream (§9 Events). */ export const SSE_TERMINAL_TYPES = ["operation.completed", "operation.failed"] as const; /** True iff `t` is a contract SSE event type. Anything else is a protocol violation * the client must FAIL CLOSED on (AGENTS.md: unknown series/event → de-arm, never * silently ignore). */ export function isSseEventType(t: string): t is SseEventType { return (SSE_EVENT_TYPES as readonly string[]).includes(t); } export function isTerminalSseEvent(t: string): t is (typeof SSE_TERMINAL_TYPES)[number] { return (SSE_TERMINAL_TYPES as readonly string[]).includes(t); } /* ─────────────────────────────── wire shapes ───────────────────────────── */ /** openapi TrialCapability. `capability` is the bearer token (NOT `capabilityId`). */ export interface WireTrialCapability { readonly capability: string; readonly subject_commitment: string; readonly expiry: string; readonly scopes: readonly Scope[]; readonly rate_limit?: { readonly requests_per_minute?: number; readonly compute_quota?: string }; } /** openapi Diagnostic (validate/parse diagnostics; also a ValidationProblem member). */ export interface WireDiagnostic { readonly severity: "error" | "warning" | "hint"; readonly code: string; readonly message: string; readonly span?: { readonly line?: number; readonly column?: number; readonly length?: number }; } /** openapi ValidateResult (200 from POST /validate; pure — no Operation). */ export interface WireValidateResult { readonly valid: true; readonly artifact_id?: string; readonly diagnostics?: readonly WireDiagnostic[]; } export type WireOperationStatus = "running" | "completed" | "suspended" | "failed"; /** openapi Operation. `cursor` is an opaque STRING; `status` (not `state`); the * domain artifacts ride as opaque refs, the real payload arrives over SSE. */ export interface WireOperation { readonly operation_id: string; readonly intent_hash: string; readonly status: WireOperationStatus; readonly checkpoint?: string; readonly cursor: string; readonly artifacts?: readonly unknown[]; readonly receipts?: readonly unknown[]; readonly offer?: WireOffer | null; readonly continuation?: unknown | null; } /** openapi Money — a decimal string + asset code (NOT a bare number). */ export interface WireMoney { readonly value: string; readonly asset: string; } /** openapi SettlementMethod — discriminated on `method` (NOT `kind`). */ export interface WireSettlementMethod { readonly method: "stripe-mpp" | "x402"; readonly network?: string; readonly accepts?: readonly string[]; readonly params?: Record<string, unknown>; } /** openapi HumanAction — the contextual human path; `url` is DATA, never followed. */ export interface WireHumanAction { readonly kind: "claim-and-fund"; readonly url: string; readonly required: boolean; readonly label?: string; } /** openapi Proof — the free result already earned at the sealed checkpoint. */ export interface WireProof { readonly proof_id: string; readonly operation_id: string; readonly intent_hash?: string; readonly artifacts?: readonly unknown[]; readonly receipts?: readonly unknown[]; readonly url?: string; } /** openapi Offer — `amount` is {@link WireMoney}; bound to exactly one Operation. */ export interface WireOffer { readonly offer_id: string; readonly operation_id: string; readonly intent_hash: string; readonly scope: readonly Scope[]; readonly ceiling: number; readonly budget?: number; readonly amount: WireMoney; readonly terms_digest: string; readonly expiry: string; readonly nonce: string; readonly signature?: string; readonly requires_human?: boolean; } /** openapi OfferResponse — the STRUCTURED 402 body (§"The 402 Offer"). Every face * surfaces it as DATA; none may silently redirect into a browser checkout. */ export interface WireOfferResponse { readonly operation_id: string; readonly offer: WireOffer; readonly proof?: WireProof | null; readonly settlement_methods: readonly WireSettlementMethod[]; readonly human_action?: WireHumanAction; } /** openapi OperationEvent — the JSON `data:` payload of an SSE frame; `cursor` * echoes the SSE `id:`; the opaque domain payload rides in `data`. */ export interface WireOperationEvent { readonly cursor: string; readonly type: SseEventType; readonly operation_id: string; readonly data?: Record<string, unknown>; } /** * openapi Operation.resume (ResumeAffordance) — the self-describing next call that drives a * NON-TERMINAL Operation forward (bead kestrel-markets-770.14, ADR-0009). It names the SAME * canonical `POST /api/simulate` the caller made, re-driving the SAME durable Operation from * `cursor` without repeating completed work. `requires_settlement:true` marks the SPEND * BOUNDARY (Operation `status:"suspended"`): the attached `offer` must be settled before the * resume POST. `detail` is the actionable protocol note the platform ships inline. */ export interface WireResumeAffordance { readonly method: "POST"; readonly path: string; readonly operation_id: string; readonly cursor: string; readonly requires_settlement: boolean; readonly detail: string; } /** * openapi SettlementAffordance — the machine-followable SETTLE rail an Offer advertises * (bead kestrel-markets-1gr8, PR #334). Embedded in the 402 OfferResponse and, from * wave-3, in the suspended `SimOperationResponse` too: the EXACT wired URL an agent POSTs * settlement evidence to (`POST /api/operations/{id}/settlement`), the method, the accepted * rails, and the `SettlementRequest` body shape. This is "where to actually pay" — distinct * from the {@link WireResumeAffordance} `resume` (which re-drives `/api/simulate` AFTER the * Offer is settled). Optional on the wire: a response minted before the rail was wired * carries none, so the CLI degrades to the resume affordance rather than fabricating a URL. */ export interface WireSettlementAffordance { readonly method: "POST"; readonly url: string; readonly accepts?: readonly string[]; readonly body_schema?: { readonly offer_id: string; readonly method: string; readonly evidence: { readonly provider_payment_id: string }; }; readonly detail?: string; } /* ─────────────────────────── decoders (pure) ───────────────────────────── */ /** Decode a wire {@link WireTrialCapability} into the OSS {@link TrialCapability}. * The bearer token (`capability`) becomes the OSS `capabilityId` (the capability id * IS the bearer for a trial cap); `rate_limit.requests_per_minute` → the OSS * requests-per-window shape. */ export function decodeTrialCapability(w: WireTrialCapability): TrialCapability { return { kind: "trial", capabilityId: w.capability, scopes: w.scopes, expiry: w.expiry, subjectCommitment: w.subject_commitment, rateLimit: { limit: w.rate_limit?.requests_per_minute ?? 0, windowSeconds: 60 }, }; } /** Map a wire operation `status` onto the OSS {@link OperationState}. */ function decodeStatus(w: WireOperation): OperationState { switch (w.status) { case "running": return "open"; case "suspended": return w.offer?.requires_human ? "awaiting-human" : "awaiting-settlement"; case "completed": return "completed"; case "failed": return "failed"; default: { // fail-closed: an unmodelled status is a protocol drift, not a silent default. const never: never = w.status; throw new Error(`wire: unknown Operation.status ${JSON.stringify(never)}`); } } } /** Decode a wire {@link WireOperation} into the OSS {@link Operation}. The opaque * wire `cursor` string is carried verbatim in `EventCursor.token` (its `sequence` * is not on the wire — the cursor is an opaque bookmark, handed back by string). */ export function decodeOperation(w: WireOperation): Operation { return { operationId: w.operation_id, intentHash: w.intent_hash, state: decodeStatus(w), cursor: { sequence: 0, token: w.cursor }, ...(w.checkpoint !== undefined ? { checkpoint: w.checkpoint } : {}), authorityEpoch: 0, resumeGeneration: 0, }; } /** * Fail-closed structural check that `v` is a SETTLEABLE {@link WireOffer} — a full, * platform-signed Offer carrying terms (an `offer_id`, a non-empty `scope[]`, a finite * `ceiling`, and a {@link WireMoney} `amount`), NOT the degraded `{offer_id, operation_id}` * boundary fallback the platform emits when no signing key is wired (session.ts@toResponse). * The bare fallback carries no itemized terms to surface as data, so it returns `null` and the * caller keeps its existing typed behavior — never a silent default, never a guessed Offer. */ export function asWireOffer(v: unknown): WireOffer | null { if (typeof v !== "object" || v === null) return null; const o = v as Record<string, unknown>; if (typeof o["offer_id"] !== "string" || typeof o["operation_id"] !== "string") return null; if (!Array.isArray(o["scope"]) || o["scope"].length === 0) return null; if (typeof o["ceiling"] !== "number" || !Number.isFinite(o["ceiling"])) return null; const amount = o["amount"]; if (typeof amount !== "object" || amount === null || !("value" in amount) || !("asset" in amount)) return null; return v as WireOffer; } /** * Fail-closed check that `v` is a SETTLEMENT-GATED {@link WireResumeAffordance} bound to * `operationId`: the canonical `POST /api/simulate` re-drive with `requires_settlement:true`. * Anything else (a non-canonical path, a foreign operation, `requires_settlement` other than * `true`) returns `null` — the Spend-boundary shape is recognized exactly, or not at all. */ export function asSettlementResume(v: unknown, operationId: string): WireResumeAffordance | null { if (typeof v !== "object" || v === null) return null; const r = v as Record<string, unknown>; if (r["method"] !== "POST" || r["path"] !== "/api/simulate") return null; if (r["operation_id"] !== operationId) return null; if (typeof r["cursor"] !== "string") return null; if (r["requires_settlement"] !== true) return null; if (typeof r["detail"] !== "string") return null; return v as WireResumeAffordance; } /** * Fail-closed check that `v` is a well-formed {@link WireSettlementAffordance}: the wired * settle rail an Offer advertises (`POST /api/operations/{id}/settlement`). Requires the two * load-bearing fields an agent needs to REACH the rail — `method:"POST"` and a non-empty * `url` — and shape-checks the optional descriptive fields when present. Returns `null` for a * response that carries no settle affordance (minted before the rail was wired, bead * kestrel-markets-1gr8), so the caller degrades to the resume affordance rather than printing * a fabricated URL — never a silent default. */ export function asSettlementAffordance(v: unknown): WireSettlementAffordance | null { if (typeof v !== "object" || v === null) return null; const s = v as Record<string, unknown>; if (s["method"] !== "POST") return null; if (typeof s["url"] !== "string" || s["url"].length === 0) return null; if (s["accepts"] !== undefined && !Array.isArray(s["accepts"])) return null; if (s["detail"] !== undefined && typeof s["detail"] !== "string") return null; return v as WireSettlementAffordance; } /** Fail-closed structural check that a decoded 402 body is a real OfferResponse * (the required members per openapi OfferResponse). Returns the narrowed value or * `null` — the caller turns `null` into a loud error, never a silent default. */ export function asOfferResponse(body: unknown): WireOfferResponse | null { if (typeof body !== "object" || body === null) return null; const o = body as Record<string, unknown>; const offer = o["offer"]; if (typeof o["operation_id"] !== "string") return null; if (typeof offer !== "object" || offer === null) return null; const of = offer as Record<string, unknown>; const amount = of["amount"]; if (typeof of["offer_id"] !== "string" || !Array.isArray(of["scope"])) return null; if (typeof amount !== "object" || amount === null || !("value" in amount) || !("asset" in amount)) return null; if (!Array.isArray(o["settlement_methods"])) return null; return body as WireOfferResponse; }