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
text/typescript
/**
* 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" };
}