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.
200 lines (199 loc) • 9.53 kB
JavaScript
/**
* # 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).
*/
/**
* 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.js";
/* ───────────────────────────── 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",
];
/** The two terminal event types that end an Operation's stream (§9 Events). */
export const SSE_TERMINAL_TYPES = ["operation.completed", "operation.failed"];
/** 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) {
return SSE_EVENT_TYPES.includes(t);
}
export function isTerminalSseEvent(t) {
return SSE_TERMINAL_TYPES.includes(t);
}
/* ─────────────────────────── 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) {
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) {
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 = 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) {
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) {
if (typeof v !== "object" || v === null)
return null;
const o = v;
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;
}
/**
* 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, operationId) {
if (typeof v !== "object" || v === null)
return null;
const r = v;
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;
}
/**
* 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) {
if (typeof v !== "object" || v === null)
return null;
const s = v;
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;
}
/** 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) {
if (typeof body !== "object" || body === null)
return null;
const o = body;
const offer = o["offer"];
if (typeof o["operation_id"] !== "string")
return null;
if (typeof offer !== "object" || offer === null)
return null;
const of = offer;
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;
}