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.

380 lines (346 loc) 17.3 kB
/** * kestrel.markets/protocol — the incremental SESSION contract (protocol v0.2). * * This module is the L0 CONTRACT of the transport-neutral Session interaction * (djm.2 AC3/AC4; the schema the djm.4 controller and every face encode over). * It is the hash-chained transcript vocabulary of ONE agent-day: a genesis seals * the pure SessionSpec (SessionId = genesis hash); turn entries carry the binding * pentad `(sessionId, ordinal, parentHash, frameRoot)` plus the exact authored * bytes; a finalize seal closes the chain into a receipt root a CertifiedGrade * can pin. Idempotency IS content-addressing; a changed-bytes-same-slot turn * FAILS CLOSED. No face may reimplement Session progression — the wire verbs * deliberately omit an `advance`. * * HARD CONSTRAINTS (identical to src/protocol/index.ts — see its header): * - ZERO RUNTIME dependencies. The ONLY import is intra-protocol * (`./index.ts`, itself dependency-free); this module pulls in NO engine / * session / blotter / fill / lang / grade / frame / render / ledger / cli * code and must typecheck with `chdb` uninstalled. The import-graph boundary * is an automated CI invariant (tests/protocol.boundary.test.ts). * - DETERMINISM. Pure types + own constants; no wall clock, no RNG. * - FAIL CLOSED. Unknown / changed-bytes-same-identity ⇒ a typed diagnostic, * never a silent accept. * - GENERIC ONLY. No founding-app tickers or strategy names in types, comments, * or examples. Product identities ride INSIDE these fields at runtime. */ import type { EventCursor } from "./index.ts"; /* ───────────────────────── Operation vs Session identity ───────────────────── */ declare const OperationIdBrand: unique symbol; declare const SessionIdBrand: unique symbol; /** * CONTROL-PLANE identity (ADR-0004). One durable, resumable Operation carries a * canonical agent intent across the free boundary, machine settlement, human * completion, callbacks, and retries WITHOUT changing. It is minted by the * platform and is NOT derived from content — distinct in kind from a * {@link SessionId}. Branded so the two identities are never interchangeable. */ export type OperationId = string & { readonly [OperationIdBrand]: "OperationId" }; /** * DETERMINISTIC identity: the genesis-entry hash of a Session's transcript. Same * SessionSpec ⇒ same genesis ⇒ same `SessionId`, on any host (a self-hosted * Kestrel and the managed backend agree). Branded distinctly from * {@link OperationId}: a deterministic Session identity is NOT a control-plane * handle, and the compiler refuses to substitute one for the other. */ export type SessionId = string & { readonly [SessionIdBrand]: "SessionId" }; /* ─────────────────────────── the reserved OPEN ordinal ─────────────────────── */ /** * Turn ordinal of the date-blind OPEN Frame — the author's first answer, before * any Wake. Reserved as `-1` so real Wake ordinals stay `0,1,2,…` and the OPEN * turn is never confused with a Wake turn. */ export const OPEN_ORDINAL = -1 as const; /* ───────────────────────────── turn bodies ─────────────────────────────────── */ /** * Why a turn was recorded as a host failure (djm.4 fail semantics 3). A recorded * failure is FOREVER distinguishable from a judged STAND_DOWN — a closed * vocabulary so a downstream reader can dispatch exhaustively. */ export type FailureClass = "provider" | "timeout" | "malformed"; /** Runtime tuple of every {@link FailureClass}. Same both-ways guard as the index rdu vocabularies. */ export const FAILURE_CLASSES = ["provider", "timeout", "malformed"] as const satisfies readonly FailureClass[]; type _FailureClassesExhaustive<_Missing extends never = Exclude<FailureClass, (typeof FAILURE_CLASSES)[number]>> = true; /** * What an author actually produced for a turn — the three OUTCOMES kept forever * distinguishable (djm.4 fail semantics; the bead's distinguishability * criterion becomes a recorded fact): * - `authored` a Kestrel document was authored; `bytes` are the exact source. * - `stand-down` the author EXPLICITLY stood down (a judgment, gradable) with a * stated reason — NOT a failure. * - `failure` the host recorded a provider/timeout/malformed failure — the * author never got to answer; never confused with a STAND_DOWN. */ export type TurnBody = | { readonly kind: "authored"; readonly bytes: string } | { readonly kind: "stand-down"; readonly reason: string } | { readonly kind: "failure"; readonly failureClass: FailureClass }; /* ──────────────────────────── transcript entry ─────────────────────────────── */ /** * One turn's transcript entry — the content-addressed record of an answer bound * to its SLOT. The BINDING PENTAD `(sessionId, ordinal, parentHash, frameRoot)` * is the turn's SEMANTIC-EFFECT IDENTITY: the position in the chain the author * answered. `authoredSha256` addresses the authored bytes; `entryHash` addresses * the whole entry (its content address — the next turn's `parentHash`). */ export interface TurnEntry { readonly kind: "turn"; /** The Session this turn belongs to (= genesis hash). */ readonly sessionId: SessionId; /** Turn ordinal; {@link OPEN_ORDINAL} for the OPEN turn, `0,1,2,…` for Wakes. */ readonly ordinal: number; /** Content address of the PRIOR entry (the chain link / prior cursor). */ readonly parentHash: string; /** sha256 of the canonical delivered Frame this turn answers. */ readonly frameRoot: string; /** sha256 of the exact authored bytes (idempotency address). */ readonly authoredSha256: string; /** What the author produced. */ readonly body: TurnBody; /** Content address of THIS whole entry (the next turn's `parentHash`). */ readonly entryHash: string; } /* ─────────────────────── closed fail-closed diagnostics ────────────────────── */ /** * The closed diagnostic vocabulary of the Session layer (djm.4 L0). Every * rejection is one of these typed reasons — never a crash, never a silent false: * - `wrong-session` the entry names a different Session. * - `broken-chain` the `parentHash`/ordinal does not continue the chain. * - `turn-conflict` changed bytes under the SAME semantic-effect slot (AC4). * - `stale-frame` the turn answers a superseded / non-current Frame. * - `not-pending` no turn is pending at this slot (phase gate; djm.4 L1). * - `sealed` the chain is finalized; no further turns admit (djm.4 L1). * - `malformed-entry` the entry is structurally unreadable. */ export type SessionDiagnostic = | "wrong-session" | "broken-chain" | "turn-conflict" | "stale-frame" | "not-pending" | "sealed" | "malformed-entry"; /** * Runtime tuple of every {@link SessionDiagnostic}, in the L0 order. Same * both-ways closed-vocabulary guard as the index rdu vocabularies: a diagnostic * added to (or removed from) the union without editing this tuple breaks the * build, so the runtime vocabulary can never silently drift from the type. */ export const SESSION_DIAGNOSTICS = [ "wrong-session", "broken-chain", "turn-conflict", "stale-frame", "not-pending", "sealed", "malformed-entry", ] as const satisfies readonly SessionDiagnostic[]; type _SessionDiagnosticsExhaustive<_Missing extends never = Exclude<SessionDiagnostic, (typeof SESSION_DIAGNOSTICS)[number]>> = true; /* ──────────────────────── incremental message union ────────────────────────── */ /** * `session/open` — START: mint a Session against a sealed genesis. `sessionId` * (base) is the genesis hash; `genesisRoot` is the content root of the pure * SessionSpec the genesis seals. Optionally binds the control-plane * {@link OperationId} carrying this Session. */ export interface OpenMessage { readonly kind: "session/open"; readonly sessionId: SessionId; readonly operationId?: OperationId; /** Content root of the sealed SessionSpec (pure data) the genesis pins. */ readonly genesisRoot: string; } /** * `session/frame` — OPEN DELIVERY and WAKE-DELTA: deliver the Frame the author * must answer at `ordinal` ({@link OPEN_ORDINAL} for OPEN, `0,1,…` for a Wake * delta), carrying its content-addressed `frameRoot` and the `parentHash` (prior * cursor) the answer must bind to. */ export interface FrameMessage { readonly kind: "session/frame"; readonly sessionId: SessionId; readonly ordinal: number; readonly frameRoot: string; readonly parentHash: string; } /** * `session/turn` — AUTHORED-OR-STAND-DOWN and REVISION: the author's answer to a * delivered slot. Carries the full binding pentad header plus the {@link TurnBody}. * A revision is another turn at the SAME slot; reconciliation ({@link reconcileTurn}) * decides idempotent-duplicate vs. fail-closed conflict. */ export interface TurnMessage { readonly kind: "session/turn"; readonly sessionId: SessionId; readonly ordinal: number; readonly parentHash: string; readonly frameRoot: string; readonly authoredSha256: string; readonly body: TurnBody; } /** * `session/events` — RESUME: replay graded events after a cursor. The SSE event * id IS the {@link EventCursor} token, so `Last-Event-ID` resume and this request * are one mechanism (ADR-0004 resumable cursors). Absent `after` ⇒ from genesis. */ export interface EventsMessage { readonly kind: "session/events"; readonly sessionId: SessionId; readonly after?: EventCursor; } /** * `session/describe` — self-describe the Session's pinned catalog entry / spec. * `entryRoot` is the content root of the CatalogEntry (see ./catalog.ts) this * Session runs, so a client can fetch the full reproducibility surface. */ export interface DescribeMessage { readonly kind: "session/describe"; readonly sessionId: SessionId; readonly entryRoot: string; } /** * `session/finalize` — FINALIZATION and ARTIFACTS: seal the chain. `tipHash` is * the receipt root a CertifiedGrade can pin; `artifacts` are content-addressed * handles to the delivered Blotter/Grade/report artifacts. */ export interface FinalizeMessage { readonly kind: "session/finalize"; readonly sessionId: SessionId; readonly tipHash: string; readonly artifacts: readonly string[]; } /** * The incremental Session wire message union (djm.2 AC3). Discriminated on * `kind`. Every message binds the {@link SessionId}; turn/frame messages bind the * prior cursor and delivered Frame root. Deliberately NO `advance` verb: a face * is a pure encoder over the controller and structurally cannot reimplement * Session progression (djm.4 FACES). */ export type SessionMessage = | OpenMessage | FrameMessage | TurnMessage | EventsMessage | DescribeMessage | FinalizeMessage; /** The `kind` discriminant of {@link SessionMessage} — the frozen wire verb space. */ export type SessionMessageKind = SessionMessage["kind"]; /** * Every Session wire verb as a runtime tuple — the closed `kind` vocabulary of * {@link SessionMessage}. Driven by the union itself (`satisfies readonly * SessionMessage["kind"][]`) so adding a message forces this tuple to grow in * lockstep. Same both-ways closed-vocabulary guard as `RECEIPT_KINDS`: a `kind` * added to (or removed from) the union without editing this tuple breaks the * build. Fail-closed, no drift — and no `advance`. */ export const SESSION_MESSAGE_KINDS = [ "session/open", "session/turn", "session/frame", "session/events", "session/describe", "session/finalize", ] as const satisfies readonly SessionMessage["kind"][]; type _SessionMessageKindsExhaustive<_Missing extends never = Exclude<SessionMessage["kind"], (typeof SESSION_MESSAGE_KINDS)[number]>> = true; /* ─────────────────────── required incremental interactions ─────────────────── */ /** * The incremental interactions djm.2 AC3 enumerates, in lifecycle order. Each is * SERVED BY a wire verb via {@link INTERACTION_MESSAGE}; they are the lifecycle * STEPS, distinct from the {@link SessionMessageKind} VERBS that carry them. */ export type SessionInteraction = | "start" | "open-delivery" | "authored-or-stand-down" | "wake-delta" | "revision" | "resume" | "finalization" | "artifacts"; /** Runtime tuple of every {@link SessionInteraction}, in lifecycle order. Same both-ways guard. */ export const SESSION_INTERACTIONS = [ "start", "open-delivery", "authored-or-stand-down", "wake-delta", "revision", "resume", "finalization", "artifacts", ] as const satisfies readonly SessionInteraction[]; type _SessionInteractionsExhaustive<_Missing extends never = Exclude<SessionInteraction, (typeof SESSION_INTERACTIONS)[number]>> = true; /** * The TOTAL map proving every required interaction is served by a real wire verb * — `Record<SessionInteraction, SessionMessageKind>`, so a missing interaction or * an unknown verb fails the build. Several interactions share a verb (the wire * vocabulary is intentionally smaller than the interaction set): authored / * revision both ride `session/turn`; OPEN delivery / Wake delta both ride * `session/frame`; finalization / artifacts both ride the `session/finalize` * seal. There is no bespoke verb per interaction — and still no `advance`. */ export const INTERACTION_MESSAGE: Record<SessionInteraction, SessionMessageKind> = { start: "session/open", "open-delivery": "session/frame", "authored-or-stand-down": "session/turn", "wake-delta": "session/frame", revision: "session/turn", resume: "session/events", finalization: "session/finalize", artifacts: "session/finalize", }; /* ──────────────── idempotency + fail-closed reconciliation (AC4) ───────────── */ /** * The result of reconciling an incoming turn against the entry already recorded * at its slot. Either an idempotent admit (`ok: true`, `duplicate` says whether * the incoming was a byte-identical re-send) carrying the settled `entryHash`, or * a fail-closed rejection (`ok: false`) with a typed {@link SessionDiagnostic}. */ export type AdmitResult = | { readonly ok: true; readonly duplicate: boolean; readonly entryHash: string } | { readonly ok: false; readonly diagnostic: SessionDiagnostic }; /** * Reconcile an `incoming` turn against the `prior` entry recorded at the same * intended slot — the idempotency + fail-closed primitive (djm.2 AC4; djm.4 * "idempotency IS content-addressing"). Pure and synchronous; no crypto, no I/O. * * Ladder (fail-closed, most-specific first): * 1. structurally unreadable incoming → `malformed-entry` * 2. names a different Session → `wrong-session` * 3. does not continue the chain (parent/ord) → `broken-chain` * 4. answers a superseded Frame → `stale-frame` * 5. SAME slot, SAME entry address → idempotent duplicate (`ok`) * 6. SAME slot, CHANGED bytes → `turn-conflict` (the headline * rule: a different-bytes-same-identity turn is NEVER silently accepted) * * (`not-pending` / `sealed` are phase diagnostics raised by the djm.4 L1 gate * that knows the Session's phase — not by this same-slot reconciler.) */ export function reconcileTurn(prior: TurnEntry, incoming: TurnEntry): AdmitResult { // 1. Structural readability — fail closed on an unreadable envelope. if ( incoming.entryHash.length === 0 || (incoming.body.kind === "authored" && incoming.authoredSha256.length === 0) ) { return { ok: false, diagnostic: "malformed-entry" }; } // 2. Same Session. if (incoming.sessionId !== prior.sessionId) { return { ok: false, diagnostic: "wrong-session" }; } // 3. Continues the chain (same ordinal AND same parent link). if (incoming.ordinal !== prior.ordinal || incoming.parentHash !== prior.parentHash) { return { ok: false, diagnostic: "broken-chain" }; } // 4. Answers the current Frame (not a superseded one). if (incoming.frameRoot !== prior.frameRoot) { return { ok: false, diagnostic: "stale-frame" }; } // 5. Same slot, same content address ⇒ idempotent duplicate. Content-addressing // IS idempotency: an identical entry re-sent is a no-op that returns the // settled hash. (Equal entryHash implies equal authored bytes.) if (incoming.entryHash === prior.entryHash) { return { ok: true, duplicate: true, entryHash: prior.entryHash }; } // 6. Same slot, CHANGED bytes ⇒ fail closed. Never silently accept a // different-bytes-same-semantic-identity turn. return { ok: false, diagnostic: "turn-conflict" }; }