The journal
Normative: MODEL.md §5. This is the core of ilmek and the reason it is not a LangGraph clone.
The problem
In a pure-replay engine, resuming an interrupted node re-executes it from the top. Everything before the pause runs again — so a side effect before the pause happens twice. LangGraph documents this as a rule for the author to obey. ilmek does not push it onto the author. The engine remembers.
The contract
A node body must be deterministic modulo steps. Every side effect and every nondeterministic read — clock, RNG, uuid, network, DB, LLM call — must be wrapped in a step. Given the same state and the same journal, a node must request the same steps.
Obey it and replay is invisible. Violate it and strict mode tells you where.
Semantics
ctx.step(key, fn) does exactly this:
- Look
keyup in the task's journal. - Hit → return the recorded value.
fnis not called. - Miss → call
fn, append{ key, value }to the journal, persist the journal, return the value.
So on the pass after an interrupt, the node re-runs from the top, but every step it already completed returns instantly from the journal:
- TypeScript
- .NET (C#)
import { graph, channel, START, END, run, resume, InMemoryCheckpointer } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";
// Stand-in services — swap for your real order + payment code.
const Orders = { create: (cart: string[]) => ({ id: "ord-9001", total: 249.9 }) };
const Payments = { charge: (order: { id: string; total: number }, ok: string) => "charged" };
/** Checkout state — the cart in, an append-only log out. */
const CheckoutState = {
cart: channel.lastWrite<string[]>([]),
log: channel.append<string>(),
} satisfies ChannelMap;
type CheckoutState = StateOf<typeof CheckoutState>;
const g = graph("checkout", CheckoutState)
.node("checkout", async (state, ctx) => {
// 1st pass: calls Orders.create, journals the result.
// resume pass: returns the journaled order. Orders.create is NOT called.
const order = await ctx.step("create_order", () => Orders.create(state.cart));
// 1st pass: no answer in the journal → the task halts here.
// resume pass: returns the user's answer from the journal.
const answer = await ctx.interrupt<string>({ question: `Charge ${order.total}?` });
// Only ever reached on the resume pass.
await ctx.step("charge", () => Payments.charge(order, answer));
return { log: ["done"] };
})
.edge(START, "checkout").edge("checkout", END)
.compile();
const opts = { threadId: "conv-42", checkpointer: new InMemoryCheckpointer() };
const paused = await run(g, { cart: ["coffee", "mug"] }, opts); // status: "interrupted"
const done = await resume(g, "yes", opts); // status: "done"
using Ilmek;
// Stand-in services — swap for your real order + payment code.
static class Orders { public static (string Id, decimal Total) Create(IReadOnlyList<string> cart) => ("ord-9001", 249.9m); }
static class Payments { public static string Charge((string Id, decimal Total) order, string ok) => "charged"; }
/// <summary>Checkout state — the cart in, an append-only log out.</summary>
sealed class CheckoutState
{
public List<string> Cart { get; set; } = new();
[Append] public List<string> Log { get; set; } = new();
}
var g = Graph.Create<CheckoutState>("checkout")
.Node("checkout", async (state, ctx) =>
{
// 1st pass: calls Orders.Create, journals the result.
// resume pass: returns the journaled order. Orders.Create is NOT called.
var order = await ctx.StepAsync("create_order", () => Orders.Create(state.Cart));
// 1st pass: no answer in the journal → the task halts here.
// resume pass: returns the user's answer from the journal.
var answer = await ctx.InterruptAsync<string>(new { question = $"Charge {order.Total}?" });
// Only ever reached on the resume pass.
await ctx.StepAsync("charge", () => Payments.Charge(order, answer));
return Update.For<CheckoutState>().Append(s => s.Log, "done");
})
.Edge(Graph.Start, "checkout")
.Edge("checkout", Graph.End)
.Compile();
var opts = new RunOptions { ThreadId = "conv-42", Checkpointer = new InMemoryCheckpointer() };
var input = Update.For<CheckoutState>().Set(s => s.Cart, new List<string> { "coffee", "mug" });
var paused = await IlmekRuntime.RunAsync(g, input, opts); // Status: Interrupted
var done = await IlmekRuntime.ResumeAsync(g, "yes", opts); // Status: Done
Orders.create runs exactly once across both passes. That is what resume from
the line means: not a restored call stack, but an effect that cannot happen
twice.
Keys
Keys are explicit strings, looked up by name — not by call order. A node may branch and skip steps; lookup by name stays correct.
Colliding keys within one task are auto-suffixed by occurrence: "charge" called
three times journals charge#0, charge#1, charge#2. This restores
order-dependence for that key, so loops should carry a stable key derived from
the data:
- TypeScript
- .NET (C#)
import { graph, channel, START, END, run } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";
// Stand-in payment service — swap for your real payment code.
const Payments = { charge: (item: { id: string }) => "charged" };
/** Cart state — one line item per product, each with a stable id. */
const CartState = {
cart: channel.lastWrite<{ id: string }[]>([]),
log: channel.append<string>(),
} satisfies ChannelMap;
type CartState = StateOf<typeof CartState>;
const g = graph("charge-each", CartState)
.node("charge", async (state, ctx) => {
for (const item of state.cart) {
await ctx.step(`charge:${item.id}`, () => Payments.charge(item)); // stable
}
return { log: ["done"] };
})
.edge(START, "charge").edge("charge", END)
.compile();
const { status } = await run(g, { cart: [{ id: "sku-1" }, { id: "sku-2" }] });
console.log(status); // "done"
using Ilmek;
// Stand-in payment service — swap for your real payment code.
static class Payments { public static string Charge(Item item) => "charged"; }
/// <summary>A cart line item with a stable id.</summary>
sealed record Item(string Id);
/// <summary>Cart state — one line item per product, each with a stable id.</summary>
sealed class CartState
{
public List<Item> Cart { get; set; } = new();
[Append] public List<string> Log { get; set; } = new();
}
var g = Graph.Create<CartState>("charge-each")
.Node("charge", async (state, ctx) =>
{
foreach (var item in state.Cart)
{
await ctx.StepAsync($"charge:{item.Id}", () => Payments.Charge(item)); // stable
}
return Update.For<CartState>().Append(s => s.Log, "done");
})
.Edge(Graph.Start, "charge")
.Edge("charge", Graph.End)
.Compile();
var input = Update.For<CartState>().Set(s => s.Cart, new List<Item> { new("sku-1"), new("sku-2") });
var result = await IlmekRuntime.RunAsync(g, input);
Console.WriteLine(result.Status); // Done
Journaled values must survive a serializer round-trip: what a step returns on a fresh call and what it returns from the journal must be equal. A step returning a PID, socket, or stream handle violates this — return an id and re-resolve it.
Strict mode
When enabled (default in dev/test), the engine records the observed key
sequence and compares it against the journal on the next replay. A journaled key
that the replay never requests — or a divergent order for an auto-suffixed key —
raises NondeterminismError naming the key. This turns a silent double-charge
into a loud test failure.
Lifetime
A journal is scoped to a task — (thread, checkpoint, node) — and is
discarded when that task completes and its update is reduced. It is replay
memory, not history. Checkpoints are the durable
record.