The FFI seam. An app plugs into the sequencer by implementing
Application. The sequencer
assumes the contracts below without runtime enforcement — it links the app,
calls it on the hot path and during catch-up, and trusts it to be a pure
deterministic state machine. A violation is not caught; it surfaces as
scheduler/sequencer divergence, which under rollup semantics is
theft-equivalent (threat model, "Self-trust").
This document owns the contract. AGENTS.md §"Application
Trait Contract" is the map. The placeholder wallet
(examples/app-core/) is the reference impl; a
production app will wrap a Cartesi Machine behind the same trait.
| Method | Mutates? | Clock advance | Failure contract |
|---|---|---|---|
validate_user_op(sender, op, current_fee) -> Result<(), InvalidReason> |
No — pure, read-only | — | Err(InvalidReason) ⇒ op skipped, no state change |
execute_valid_user_op(valid, safe_block) -> Result<AppOutputs, AppError> |
Yes | clock = max(clock, safe_block) |
Internal is fatal (see Replay safety) |
execute_direct_input(input) -> Result<AppOutputs, AppError> |
Yes | clock = max(clock, input.block_number) |
Internal is fatal |
MAX_METHOD_PAYLOAD_BYTES is the app's declared upper bound on a method
payload's encoded size (selector + args). The sequencer treats it as a sizing
input, not a gate: it derives the per-op byte cost
(SignedUserOp::max_batch_metadata() + A::MAX_METHOD_PAYLOAD_BYTES,
inclusion_lane/mod.rs) to
compute how many ops fit a batch's byte budget. The app must not emit a method
payload larger than it declares.
User ops are never executed by calling execute_valid_user_op directly.
They go through the free function
validate_and_execute_user_op,
which enforces the protocol guard max_fee ≥ current_fee before app
validation, then calls validate_user_op, then execute_valid_user_op. It is a
free function — not an overridable trait method — precisely so no impl can skip
the guard. Both consumers (the inclusion lane and the canonical scheduler) call
it; that shared call path is half of the
duality
agreement.
Consequently validate_user_op must not re-implement the max_fee guard as
its contract (the free function already owns it); it checks only app-level
predicates — nonce match for user-op replay protection, and fee-balance
coverage. (An impl may additionally check max_fee, as the placeholder does,
but must not rely on being the only guard.)
Execution must be a pure function of (input, current state):
- No
SystemTime::now(),HashMap/HashSetiteration order, floating point, threads, or any other nondeterminism in a consensus path. validate_user_opis pure and read-only — no mutation, no time dependence, no randomness. State changes flow exclusively through the two execute methods. Mutating fromvalidate_user_opbreaks replay (validation runs on a different schedule than execution).- The same bytes against the same state must always produce the same outcome and
the same
AppOutputs. This is what lets the off-chain mirror predict the canonical fold and what lets recovery'sfold_replayreconstruct state from L1 alone.
The sequencer persists every executed input and, on restart, replays them in order against a fresh instance to rebuild state (catch-up). Therefore:
- Any input that executed successfully live must execute successfully on
replay. Catch-up treats
AppError::Internalas fatal — it aborts startup and the sequencer cannot resume. Never returnInternalfor a byte sequence that previously succeeded. - Prefer
ExecutionOutcome::Invalidfor malformed or ill-typed input caught at the app level —Invalidis replay-safe (it deterministically skips, live and on replay). ReserveAppError::Internalfor genuine invariant violations ("validated user op cannot pay fee") — real bugs, deliberately fatal, not adversarial inputs.
last_executed_safe_block() -> u64 returns the maximum block carried by any
input this instance has executed (frame safe_block for user ops, L1
inclusion_block for directs), or 0 if nothing has executed.
- It is carried in execution, not a setter — every execute method advances
it via
max, so an app cannot execute an input and forget to move the clock. - It must survive
create_dump/from_dumpround-trips (it is part of the logical state a dump captures). - Recovery reads it as
A, the safe block a checkpoint state reflects, and the(A, B]fridge range is reconstructed from it (cockroach recovery). A wrong clock mis-defines that range.
executed_input_count() -> u64 is a diagnostic seam — replay/catch-up and the
snapshot byte-comparison compare a live instance against a replayed one with it.
The snapshot lifecycle (snapshots) drives four
dump methods. Self-typed methods load/construct; the associated functions are
pure over the path:
create_dump(prefix)—prefixmust not already exist; the impl creates and populates it. Durability: onOk, the dump must survive an immediate kernel crash — the impl mustfsyncthe dump's files and the directory entries referencing them (on POSIX: the prefix dir and its parent) before returning. The sequencer inserts the DB row pointing at this path aftercreate_dumpreturns; without the in-method fsync, a crash can leave a WAL-flushed row pointing at absent bytes.from_dump(prefix)— rehydrate equivalent logical state from a dump this same impl wrote. Loading another impl's dump is undefined.create_dumpthenfrom_dumpmust round-trip: equal logical state, equallast_executed_safe_block, equalexecuted_input_count.state_file_in_dump(prefix)— a pure function ofprefix(callable without loading the dump or instantiating the app), returning a single file (not a directory) whose bytes match what an independent canonical machine'sinspect_statewould produce for the same logical state. For impls whose persistence representation already is the canonical state, this can be the same filecreate_dumpwrote (format).delete_dump(prefix)— remove a previously-created dump (GC of superseded snapshots).
Genesis construction is intentionally not on the trait — it varies per impl (CLI config for the toy wallet, a machine-image path for a CM-wrapping app) and lives on the concrete type, called by the runtime at bootstrap.
| Contract | Depended on by |
|---|---|
| Purity / determinism | the duality (off-chain prediction = canonical fold); recovery fold_replay |
Replay safety (Internal fatal) |
catch-up on every restart |
| Safe-block clock survives dumps | cockroach recovery's A; snapshot offset accounting |
create_dump in-method fsync |
crash-safety of the dump/row ordering (I13) |
state_file_in_dump = canonical bytes |
the watchdog / indexers reading finalized state |
| One execution entry point | the max_fee protocol guard's non-bypassability |
InvalidReason is the protocol's rejection vocabulary — these produce no state
mutation and are not persisted:
InvalidNonce— user-op replay protection.InvalidMaxFee— the op won't pay the current frame fee (log-space exponent, base 129/128; seefee).InsufficientFeeBalance— the sender cannot cover the fee. "Fee", not "gas": it tracks DA, not compute.
Deposits are direct-input-only (L1 → L2) and must never be represented as
user ops; that is why execute_direct_input is required (no default) — a no-op
default would silently strand every deposit.