Deterministic replay
Deterministic replay means folding the same event log always produces the same byte-identical result, regardless of arrival order. Caisson's agent-trajectory package sorts every event by seq before folding into a RunProjection, so a shuffled batch resolves to one canonical output, the shape evals score and the audit chain anchors, safe to persist without the raw bodies it references.
In code
export function project(events: readonly TrajectoryEvent[]): RunProjection {
const ordered = [...events].sort((a, b) => a.seq - b.seq);
const runId = ordered[0]?.runId ?? "";
let status: RunProjection["status"] = "pending";
// Key order is the projection's byte order: metered → priced → estimated → unsupported
const usageTotals: Record<BillingStatus, UsageTotal> = {
metered: zeroTotal(),
priced: zeroTotal(),
estimated: zeroTotal(),
unsupported: zeroTotal(),
};
const checkpoints: CheckpointMark[] = [];
for (const e of ordered) {
switch (e.kind) {
case "step.finished": {
const node = nodes.get(e.payload.stepId);
if (node !== undefined) node.status = e.payload.status;
break;
}
// ... run.*/step.*/model.*/checkpoint each fold their own slice
}
}
return { runId, status, steps: roots, usageTotals, checkpoints };
}How it holds
Sort-by-seq before fold, every time
Both project() and projectToolCalls open with the same line ([...events].sort((a, b) => a.seq - b.seq)) before folding anything, so a shuffled batch (out-of-order stream delivery) resolves to one canonical result instead of drifting with delivery order.
Fixed key order makes the output byte-comparable
RunProjection's fields are always written in the same order (runId, status, steps, usageTotals, checkpoints) and usageTotals always carries all four billingStatus bands in a fixed sequence, so JSON.stringify of two projections of the same log is byte-identical, not merely deep-equal.
Two independent projections over the same log
project() folds run/step/usage/checkpoint state into a RunProjection; projectToolCalls folds tool.proposed/approved/denied/result into a scored tool-call list. They read the same sorted event log but never share mutable state, so adding the tool-call fold didn't change one byte of project()'s existing output.
Digest-ref payloads mean replay never needs raw bodies
Prompt text, tool arguments, and tool results live in the log only as a DigestRef ({ digest, byteLength, encRef? }); both folds reconstruct the run's shape and outcomes from the log alone, so a trajectory is safe to replay, score, or anchor without ever re-fetching the sensitive content it points at.