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.
303 lines (302 loc) • 18.5 kB
JavaScript
/**
* # grade/receipt — the immutable agent PROPOSAL / DECLINE / STAND-DOWN receipt (kestrel-a57.5)
*
* A **receipt** is the immutable, content-addressed record of one agent DECISION taken at a point in a
* Session: a plan it **armed** (accept), a plan it **declined**, or a **stand-down** (chose not to act).
* It is the curation-honest counterpart to the {@link ../blotter Blotter}: the Blotter records what the
* engine DID; a receipt records what the agent DECIDED, including the decisions that produced no order.
*
* **Curation honesty (the a57.5 doctrine).** A survivor-only ledger — one that keeps only the plans the
* agent armed — cannot grade *curation*: you cannot tell a good selector from a lucky one without the
* declines and stand-downs it left on the table. So declines and stand-downs are **MANDATORY**, not
* optional, and every receipt names the plan under decision (`plan`) so a declined/stood-down plan is a
* first-class row of the record, never dropped. (The subject plan rides the receipt directly — rather than
* living only in a frozen armory default — so curation is structural.)
*
* **What a receipt pins.** Four content hashes make a decision replayable and attributable:
* • **percept-hash** — the perceptual state the agent acted on. Today this is the sha256 of the canonical
* bus PREFIX the agent could have perceived (everything with `seq < proposed_seq`); the FULL percept-v5
* Frame (a rendered {@link ../frame Frame}) is a documented FUTURE seam (see {@link PerceptProjection}).
* • **plan-hash** — sha256 of the CANONICAL plan text, `print(parse(text))` (ADR-0004): text is a
* projection of the typed core, so the hash is invariant to whitespace/formatting, only the plan.
* • **counterfactual-hash** — the null/baseline the decision was measured against (ADR-0006:
* counterfactuals are syntax — `ungated | null | bracket`), pinned so a grade cannot silently swap it.
* • the **thesis axes** — the structured claim (when/where/how + conviction) — and the
* **measurement-version** pinning (which fill-model / grader / envelope version graded it), because EVs
* across measurement versions refuse naive comparison (ADR-0006).
*
* **Discipline (mirrors the Blotter, ADR-0011).** A receipt is IMMUTABLE and byte-stable: it is
* content-addressed (`receipt_id = "receipt:" + identity_sha256`, the sha256 over its own canonical bytes),
* so two byte-identical decisions share an id and any mutation changes the id. {@link serializeReceipt}
* is its one canonical serialization (lexicographically ordered keys, compact, one trailing newline) — the
* substrate a determinism check byte-compares. No wall clock, no RNG (RUNTIME §0). The pure PROJECTOR that
* lifts receipts off a Session bus is {@link ./receipts.ts}.
*/
import { parse, print } from "../lang/index.js";
import { sha256 } from "../crypto/sha256.js";
import { canonicalizeJson, jsonHash } from "../canonical/json.js";
import { PROBABILITY_SUM_TOLERANCE } from "../protocol/grade.js";
// ─────────────────────────────────────────────────────────────────────────────
// Determinism helpers (no wall clock, no RNG — RUNTIME §0)
// ─────────────────────────────────────────────────────────────────────────────
/** sha256 hex of a UTF-8 string (deterministic). Re-exported from the portable {@link ../crypto/sha256.ts}
* digest so a receipt hashes byte-identically under Bun and inside a Worker isolate (kestrel-alw.17). */
export { sha256 };
// The canonical byte order (recursively sorted keys, `undefined` dropped) and its content hash — one
// owner in {@link ../canonical/json.ts} (kestrel-zs2f); `canonicalizeJson`/`jsonHash` were copy-pasted
// here, byte-identical to the Blotter's, and are now a single import.
/** Is `s` a lowercase-hex sha256? (fail-closed guard on any hash-shaped field). */
export function isSha256(s) {
return typeof s === "string" && s.length === 64 && /^[0-9a-f]{64}$/.test(s);
}
/** Raised when a receipt is malformed, contradictory, or violates the curation-honesty doctrine.
* The projector catches this and fails CLOSED (a malformed decision degrades to a logged stand-down;
* it never crashes and never silently drops the row). */
export class ReceiptValidationError extends Error {
constructor(message) {
super(message);
this.name = "ReceiptValidationError";
}
}
/** Raised specifically when a plan's SOURCE text fails to parse — the "parse escape → STAND_DOWN" trigger
* (AGENTS.md). A distinct subclass so the projector's degrade-to-stand-down branch keys off the TYPE, not a
* human-readable message substring (a reworded message can never silently break the mandatory-row guarantee).
* It IS a {@link ReceiptValidationError}, so every existing fail-closed catch still catches it. */
export class PlanParseError extends ReceiptValidationError {
constructor(message) {
super(message);
this.name = "PlanParseError";
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Enumerations
// ─────────────────────────────────────────────────────────────────────────────
/** The receipt schema version. Bumped on any breaking receipt-shape change. */
export const RECEIPT_SCHEMA = "1.0.0";
/** The legal decision kinds, for fail-closed validation. */
export const DECISION_KINDS = new Set([
"accept",
"reparameterize",
"novel_plan",
"decline",
"stand_down",
]);
/** The decision kinds that ARM a plan (produce a live, executable standing) — as opposed to the no-action
* curation decisions (`decline`, `stand_down`) that leave nothing armed but MUST still be recorded. */
export const ARMING_DECISIONS = new Set([
"accept",
"reparameterize",
"novel_plan",
]);
/** The legal counterfactual kinds, for fail-closed validation. */
export const COUNTERFACTUAL_KINDS = new Set([
"ungated",
"null",
"bracket",
]);
/** The exact axis membership each thesis section must carry (fail-closed: no more, no fewer keys). */
const THESIS_AXIS_FIELDS = {
when: ["session_phase", "regime", "event", "trend_state", "volatility_state"],
where: ["product", "direction", "strike", "moneyness", "book_state"],
how: ["entry_method", "size_contracts", "reload", "invalidation", "exit"],
};
const MEASUREMENT_VERSION_FIELDS = [
"fill_model",
"commission_model",
"bus_fidelity",
"risk_envelope",
"grader",
];
// ─────────────────────────────────────────────────────────────────────────────
// Hashing primitives (the four content hashes)
// ─────────────────────────────────────────────────────────────────────────────
/**
* The **plan-hash**: sha256 of the CANONICAL plan text (ADR-0004). The source is round-tripped through
* `print(parse(source))` so the hash pins the plan's MEANING, not its formatting. Returns the canonical
* text alongside the hash. Fail-closed: a parse escape raises {@link ReceiptValidationError} (the projector
* degrades such a decision to a logged stand-down — AGENTS.md "parse escape → STAND_DOWN").
*/
export function planHash(source) {
let canonical;
try {
canonical = print(parse(source));
}
catch (err) {
throw new PlanParseError(`plan text does not parse (fail-closed → stand-down): ${err.message}`);
}
return { text: canonical, sha256: sha256(canonical) };
}
/** The **counterfactual-hash**: sha256 over the canonical counterfactual descriptor. Pins the exact
* null/baseline the decision was measured against so a grade cannot substitute a weaker one. */
export function counterfactualHash(kind) {
return jsonHash({ counterfactual: kind });
}
// ─────────────────────────────────────────────────────────────────────────────
// createReceipt — the pure content-addressing builder
// ─────────────────────────────────────────────────────────────────────────────
/**
* Build one immutable, content-addressed {@link ProposalReceipt} from a raw decision. Pure and
* deterministic (no wall clock, no RNG): the SAME input yields a byte-identical receipt. Canonicalizes the
* plan text, computes the percept/plan/counterfactual hashes, assembles the body, seals the identity
* (`identity_sha256` = sha256 over the canonical body; `receipt_id` = `"receipt:" + identity`), and
* validates fail-closed. Throws {@link ReceiptValidationError} on any contradiction.
*/
export function createReceipt(input) {
if (!DECISION_KINDS.has(input.decision_kind)) {
throw new ReceiptValidationError(`unknown decision_kind ${JSON.stringify(input.decision_kind)}`);
}
if (!COUNTERFACTUAL_KINDS.has(input.counterfactual)) {
throw new ReceiptValidationError(`unknown counterfactual ${JSON.stringify(input.counterfactual)}`);
}
// The plan under decision. Curation honesty: only a bare stand-down may name no plan; every other
// decision — INCLUDING a decline — MUST carry the plan so it is a first-class row, never dropped.
let plan;
if (input.plan_text === null) {
if (input.decision_kind !== "stand_down") {
throw new ReceiptValidationError(`${input.decision_kind} must carry the plan under decision (curation honesty: a declined/armed plan is never dropped)`);
}
plan = { text: null, sha256: null };
}
else {
const h = planHash(input.plan_text);
plan = { text: h.text, sha256: h.sha256 };
}
const body = {
schema_version: RECEIPT_SCHEMA,
record_type: "ProposalReceipt",
session_date: input.session_date,
proposed_at: input.proposed_at,
proposed_seq: input.proposed_seq,
information_cutoff: input.information_cutoff,
decision_kind: input.decision_kind,
percept: input.percept,
plan,
counterfactual: { kind: input.counterfactual, sha256: counterfactualHash(input.counterfactual) },
measurement_versions: input.measurement_versions,
thesis: input.thesis,
rationale: input.rationale ?? null,
};
const identity = jsonHash(body);
const receipt = {
...body,
identity_sha256: identity,
receipt_id: `receipt:${identity}`,
};
validateReceipt(receipt);
return receipt;
}
// ─────────────────────────────────────────────────────────────────────────────
// validateReceipt — fail-closed integrity + doctrine check
// ─────────────────────────────────────────────────────────────────────────────
function requireFiniteNumber(value, path) {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new ReceiptValidationError(`${path} must be a finite number`);
}
return value;
}
/** Validate a receipt's integrity (content-addressing, causal ordering, thesis membership) and its
* curation-honesty doctrine. Pure and total; throws {@link ReceiptValidationError} on any violation. */
export function validateReceipt(r) {
if (r.record_type !== "ProposalReceipt")
throw new ReceiptValidationError("record_type must be ProposalReceipt");
if (r.schema_version !== RECEIPT_SCHEMA) {
throw new ReceiptValidationError(`unsupported receipt schema_version ${JSON.stringify(r.schema_version)}`);
}
if (!DECISION_KINDS.has(r.decision_kind))
throw new ReceiptValidationError("unknown decision_kind");
// Content-addressing: the id IS the content.
if (!isSha256(r.identity_sha256))
throw new ReceiptValidationError("identity_sha256 must be a lowercase sha256");
if (r.receipt_id !== `receipt:${r.identity_sha256}`)
throw new ReceiptValidationError("receipt_id/identity_sha256 mismatch");
const { identity_sha256: _id, receipt_id: _rid, ...body } = r;
if (jsonHash(body) !== r.identity_sha256) {
throw new ReceiptValidationError("receipt content does not match its immutable identity");
}
// Causal ordering (RUNTIME §0): the decision follows the information it used.
requireFiniteNumber(r.proposed_at, "proposed_at");
requireFiniteNumber(r.information_cutoff, "information_cutoff");
if (r.information_cutoff > r.proposed_at) {
throw new ReceiptValidationError("information_cutoff must be no later than proposed_at");
}
if (!Number.isInteger(r.proposed_seq) || r.proposed_seq < 0) {
throw new ReceiptValidationError("proposed_seq must be a non-negative integer");
}
// Percept.
if (!isSha256(r.percept.sha256))
throw new ReceiptValidationError("percept.sha256 must be a lowercase sha256");
if (!Number.isInteger(r.percept.byte_count) || r.percept.byte_count < 0) {
throw new ReceiptValidationError("percept.byte_count must be a non-negative integer");
}
if (!Number.isInteger(r.percept.seq_watermark) || r.percept.seq_watermark < 0) {
throw new ReceiptValidationError("percept.seq_watermark must be a non-negative integer");
}
if (r.percept.seq_watermark >= r.proposed_seq) {
throw new ReceiptValidationError("percept.seq_watermark must be strictly before proposed_seq (a decision follows its percept)");
}
// Plan under decision — curation honesty.
if (r.plan.text === null) {
if (r.plan.sha256 !== null)
throw new ReceiptValidationError("null plan text cannot carry a plan-hash");
if (r.decision_kind !== "stand_down") {
throw new ReceiptValidationError(`${r.decision_kind} must carry the plan under decision (curation honesty)`);
}
}
else {
if (!isSha256(r.plan.sha256))
throw new ReceiptValidationError("plan.sha256 must be a lowercase sha256");
// The plan-hash must be the hash of the CANONICAL text (idempotent under print(parse)).
const h = planHash(r.plan.text);
if (h.text !== r.plan.text)
throw new ReceiptValidationError("plan.text is not canonical (must be print(parse(text)))");
if (h.sha256 !== r.plan.sha256)
throw new ReceiptValidationError("plan.sha256 does not match plan.text");
}
// Counterfactual-hash.
if (!COUNTERFACTUAL_KINDS.has(r.counterfactual.kind))
throw new ReceiptValidationError("unknown counterfactual kind");
if (counterfactualHash(r.counterfactual.kind) !== r.counterfactual.sha256) {
throw new ReceiptValidationError("counterfactual.sha256 does not match its kind");
}
// Measurement-version pinning — every version present and non-empty.
for (const f of MEASUREMENT_VERSION_FIELDS) {
const v = r.measurement_versions[f];
if (typeof v !== "string" || v.trim().length === 0) {
throw new ReceiptValidationError(`measurement_versions.${f} must be a non-empty string`);
}
}
// Thesis axes — exact membership + conviction/probabilities well-formed.
for (const axis of ["when", "where", "how"]) {
const section = r.thesis[axis];
const keys = Object.keys(section).sort();
const want = [...THESIS_AXIS_FIELDS[axis]].sort();
if (keys.length !== want.length || keys.some((k, i) => k !== want[i])) {
throw new ReceiptValidationError(`thesis.${axis} must contain exactly ${want.join(", ")}`);
}
}
const conviction = requireFiniteNumber(r.thesis.conviction, "thesis.conviction");
if (conviction < 0 || conviction > 1)
throw new ReceiptValidationError("thesis.conviction must be in [0,1]");
const probs = r.thesis.day_type_probabilities;
const names = Object.keys(probs);
if (names.length === 0)
throw new ReceiptValidationError("thesis.day_type_probabilities must be non-empty");
let sum = 0;
for (const name of names) {
const p = requireFiniteNumber(probs[name], `thesis.day_type_probabilities.${name}`);
if (p < 0 || p > 1)
throw new ReceiptValidationError(`thesis.day_type_probabilities.${name} must be in [0,1]`);
sum += p;
}
if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE)
throw new ReceiptValidationError("thesis.day_type_probabilities must sum to 1");
}
// ─────────────────────────────────────────────────────────────────────────────
// Canonical serialization (mirrors blotter/serialize)
// ─────────────────────────────────────────────────────────────────────────────
/** Serialize one receipt to its canonical bytes: deterministic machine-JSON (lexicographically ordered
* keys, compact, one trailing newline). Pure and total — the same receipt always produces byte-identical
* text, the substrate a determinism check byte-compares. */
export function serializeReceipt(receipt) {
return canonicalizeJson(receipt) + "\n";
}