Consensus & chain configuration
Code pointers use class names — start at
runtime/.../appchain/AppChainEngine.java (consensus core),
core-api/.../appchain/ (SPI + codecs), and runtime/.../OrderedLog.java
(the one built-in state machine).
1. Architecture map
Section titled “1. Architecture map” one Yano node┌───────────────────────────────────────────────────────┐│ Cardano L1 (sync / relay / block production) ││ │ L1 events: blocks, slots, rollbacks ││ ▼ ││ AppChainManager ── one per node ────────────────────││ │ shared INBOUND transport (appmsg, protocol 100) ││ │ shared catch-up server (protocol 103) ││ │ per-chain dispatch by chain-id ││ ▼ ││ AppChainSubsystem ── one per chain ──────────────────││ ├─ message pool (admission, backpressure) ││ ├─ AppChainEngine (consensus, single-threaded) ││ ├─ AppLedgerStore (RocksDB: blocks, MPF, indexes) ││ ├─ AppStateMachine (your application logic) ││ ├─ MemberGroup (static or governed membership) ││ └─ anchor services, observers, sinks (optional) │└───────────────────────────────────────────────────────┘ ▲ outbound app-peer connections: per chain (transport.mode=shared rides the L1 upstream session — one TCP connection per peer pair — with dedicated dials as fallback)- One inbound front, many chains. All app-message agents share protocol
id 100, so a session cannot carry one agent per chain. The manager installs
ONE gossip agent scoped to the union of hosted chain ids and dispatches
every verified envelope to the owning chain. Outbound connections are
per-chain (each chain has its own peer list). The union transport is
permissive (max of all chains’ size/TTL limits); each chain’s own limits
are re-applied per message — with one carve-out: bodies on reserved
~topics may be as large asblock.max-bytes, because a consensus proposal body IS a whole serialized block. - Everything per-chain is independent: ledger, state machine, member set, sequencer mode, block cadence, anchor policy. Chains share only the node’s networking and its L1 view.
- The engine is a serial event loop. All consensus entry points — the
propose tick, inbound
~consensus/*messages, catch-up batches — hop onto one single-threaded executor per chain. There is no locking inside the consensus logic; ordering is the concurrency model.
2. Message lifecycle
Section titled “2. Message lifecycle”submit (REST / SDK / gossip) → envelope auth: member Ed25519 signature, message-id integrity → transport limits: size (chain max-message-bytes), TTL cap → state machine validate() ← application admission (reject = 400) → pool (backpressure: full pool = 429 + counted gossip drops) → gossip to app peers (dedup by message-id) → proposer selects into a block (drops: finalized dupes, stale sender-seqs, machine-rejected; ~system topics bypass validate) → consensus round (§4) ← the only place messages become canonical → finalized: indexed by id/topic/sender, applied to state, streamed to SSE/webhooks/Kafka, provable via MPF, eventually anchored to L1Two distinct rejection tiers exist by design (§8.2): admission keeps junk out of blocks (node-local, fast, can be re-tried), while apply is consensus-enforced and never rejects — a finalized message that violates a business rule is a deterministic no-op on every member.
3. Block anatomy
Section titled “3. Block anatomy”AppBlock fields (CBOR, AppBlockCodec):
| Field | Notes |
|---|---|
version |
block format version (3) |
chainId |
chain identity |
height |
genesis = 1 |
consensusContextDigest |
commits genesis, membership, quorum, and consensus/observer profiles |
view |
certified-consensus view at this height |
prevHash |
32 zero bytes at height 1 |
l1Slot, l1BlockHash |
the stable L1 reference (§6), 0/empty when disabled |
timestamp |
proposer wall-clock millis — the ONLY time a state machine may use |
messagesRoot |
binary blake2b-256 merkle root over the ordered message ids (odd width duplicates the last node; empty list = 32 zero bytes) |
stateRoot |
MPF root AFTER applying this block (§7) |
messages |
the full ordered message envelopes |
proposer |
Ed25519 public key |
justification |
empty in view 0; canonical NewViewCertificate in higher views |
cert |
finality certificate: (scheme=ed25519, [(signer, signature)...]) |
The block hash covers the header only — blake2b-256 over
the v3 consensus identity, value fields, proposer, and justification digest.
Messages are bound via messagesRoot, history via prevHash; only the cert is
outside the hash, so it can be attached with withCert.
4. The consensus round
Section titled “4. The consensus round”Consensus messages are ordinary signed envelopes on propose, prepare,
prepared, commit, timeout, new-view, and cert topics under
~consensus/. Votes and certificates use canonical, bounded CBOR and
domain-separated signatures.
4.1 Proposing
Section titled “4.1 Proposing”Every member ticks every block.interval-ms; the framework computes the same
leader from committed context on every node, so only that member proceeds:
- If a round times out, broadcast a durable signed timeout for the next view.
- In view 0, the configured policy selects the initial leader. In higher views, the framework selects one deterministic leader from the certified context.
- Select the mandatory durable L1 prefix, then ordinary messages: cap by
block.max-bytes(primary; the serialized block is trimmed to fit) andblock.max-messages(backstop); drop already-finalized ids, stale per-sender seqs, and messages the state machine’svalidate()rejects. Reserved~topics bypass application admission — a state machine cannot veto governance or consensus traffic. - Build and apply locally to compute the real
stateRoot, persist the(height, view, blockHash)prepare lock, broadcast the proposal and PREPARE.
No pending messages → no block. An idle chain produces nothing.
4.2 Follower validation — every check, in order
Section titled “4.2 Follower validation — every check, in order”A member votes for a proposal only after ALL of:
- decodes as a block; height not already finalized;
- height is exactly
tip+1(a block from the future is deferred and retried, not dropped — the transport never re-delivers); block.proposer == envelope sender(proposer authenticity);- the framework accepts this leader for the block’s height and view;
prevHashequals the local tip hash;messagesRootrecomputes from the message list;- serialized size ≤
block.max-bytes(counted + rejected loudly); - the L1 ref is monotonic and consistent with the node’s OWN L1 view (§6.3) — mismatch rejects, “proposer slightly ahead” defers;
- every
~l1/*observation message re-derives from the node’s own L1 stream (fail-closed); - every message: valid message-id, not expired, member-signed
(membership evaluated at this height), body ≤
max-message-bytes; - per-sender seqs strictly increasing above the finalized floor (when
message.enforce-sender-seqis on); - no conflicting prepare lock in this view and no newer durable lock;
- independent re-execution: the follower applies the block itself and
requires byte-identical
stateRoot.
Then and only then: persist the prepare lock and broadcast a domain-separated PREPARE. Check 13 is the heart of the system — state is never trusted, always recomputed. A nondeterministic state machine stalls its chain right here.
4.3 Finality
Section titled “4.3 Finality”Any member holding the round aggregates PREPARE votes. At threshold, it
persists and broadcasts a PreparedQC; members then sign COMMIT. At a COMMIT
quorum, the holder assembles FinalityCert, commits, and broadcasts cert.
Cert verification never trusts the sender: scheme must be Ed25519, each signer must be a member at that height, duplicates are ignored, every signature is verified over the commit-domain digest, and the count must reach the threshold at that height.
A block is APP_FINAL once committed with a threshold cert — via own votes, a received cert notice, or catch-up (§8.1). The ledger is append-only; there is no rollback path below finality.
4.4 Timeouts and partial rounds
Section titled “4.4 Timeouts and partial rounds”A timeout never deletes a lock. A quorum of signed timeout records forms a
NewViewCertificate. The next leader must carry the highest valid PreparedQC
value byte-for-byte; without one it may build a fresh deterministic value.
Timeouts back off exponentially within configured bounds.
5. Vote locks — the safety core
Section titled “5. Vote locks — the safety core”Before PREPARE, a member persists (height, view, blockHash). It never prepares
two values in one view or moves a lock backwards. PreparedQCs and their complete
values are also durable across restart and must cross every higher view.
There is no stale-lock deletion endpoint. Expired proposals recover only via a quorum-certified higher view; L1-invalidated prepared values quarantine rather than unlock.
6. Sequencer modes and membership
Section titled “6. Sequencer modes and membership”6.1 Fixed
Section titled “6.1 Fixed”sequencer.proposer selects the view-0 leader. If it cannot complete the
round, a quorum-certified timeout advances the view and the next member in
canonical member order becomes leader. No configuration change or lock
deletion is needed.
6.2 Rotating
Section titled “6.2 Rotating”The view-0 leader is derived from the committed consensus context and parent block hash. Higher views advance through the canonically sorted member list. This gives every member the same leader even when their newest L1 tips differ. The L1 reference remains proposal data and is verified independently (§6.3); it is not the leader clock.
Both fixed and rotating policies require a quorum to advance a failed round. Choose a threshold that matches the stated fault model (§4), not merely the number of normally-online nodes.
6.3 The L1 reference (both modes)
Section titled “6.3 The L1 reference (both modes)”With l1.stability-depth > 0, every block carries an (l1Slot, l1BlockHash)
reference at least that many blocks below the L1 tip. Followers verify it
against their own L1 view: a fabricated ref is rejected fail-closed, a
ref slightly ahead of the local view is deferred and retried, and slots must
be monotonic across blocks. This pins app-chain history to L1 time — it is
what makes observation stability (user guide §5.5) and
anchor recency meaningful.
6.4 Proposer vs anchor leader (don’t conflate them)
Section titled “6.4 Proposer vs anchor leader (don’t conflate them)”Rotation applies to the proposer only. The anchor leader — the node
with anchor.enabled that builds and pays for L1 anchor txs — is a separate,
fixed role that does not rotate (one thread UTxO, one fee wallet), and it is
not a trust point: script-mode advances require threshold member
co-signatures verified against each member’s own ledger (user guide §5.1).
A rotating chain with anchoring therefore has a rotating proposer AND a
fixed anchor leader at the same time.
6.5 Membership epochs
Section titled “6.5 Membership epochs”Membership is either static config or governed: membership
changes are themselves finalized chain transactions requiring
threshold-many identical member commands. Internally the group keeps
member epochs — every consensus check above says “member at height h”,
so blocks from before a rotation verify against the membership that was
current then. Governance commands ride reserved ~governance/* topics
(which bypass state-machine admission) and their effects persist atomically
with the block that finalizes them.
7. State, the MPF trie, and proofs
Section titled “7. State, the MPF trie, and proofs”Each chain’s ledger is one RocksDB instance
(<resolved yano.app-chain.storage.path>/<chain-id>/) holding column families for
blocks, framework metadata, the message index, query indexes, and the MPF
trie nodes (Merkle Patricia Forestry — the same construction as Aiken’s
merkle-patricia-forestry, so proofs are on-chain-verifiable).
The split of responsibilities:
- The state machine and framework-authenticated keys share the trie. Every
writer.put(key, value)/delete(key)inapply()is an MPF entry — individually provable against the resulting root. The framework additionally writes reserved observation cursor keys before commit, so delivery progress is covered by the same root. - The framework owns everything else: tip metadata, per-message and topic/sender indexes, per-sender replay floors, vote locks, governance epochs — all in RocksDB CFs outside the state commitment.
- One atomic batch per block.
apply()stages trie writes into a RocksDB WriteBatch;commitBlockwrites block + tip + indexes + trie nodes + new root in a single atomic commit. A crash mid-block leaves the previous height fully intact.
stateRoot is therefore a pure, reproducible commitment to the state
machine’s data — recomputed and byte-compared by every member on every
block (§4.2 check 13), committed on-chain by anchoring (user guide §5), and
queryable per key: GET .../state/proof/{keyHex} returns the value and an MPF
inclusion proof any independent implementation can verify against an
anchored root.
8. Catch-up, restart, snapshots
Section titled “8. Catch-up, restart, snapshots”8.1 Catch-up (sync protocol 103)
Section titled “8.1 Catch-up (sync protocol 103)”Gossip never re-delivers: a missed proposal is gone from the transport. A
lagging member recovers through the dedicated block-range sync protocol —
every 5s it asks one connected peer for tip+1 .. tip+50 and applies what
comes back through the same verification gauntlet as live consensus, minus
what no longer applies:
- height/prev-hash chain intact, messages-root recomputed;
- proposer must have been the deterministic built-in leader at that height and view (custom extension modes retain their own view-0 validation);
- L1 ref monotonic + consistent with the local L1 window (a ref ahead of the local view pauses the batch, it does not fail it);
- observations re-verified fail-closed; message signatures re-verified (expiry is NOT re-checked — the messages were finalized before expiry);
- the threshold cert is fully verified, and the block is re-applied with a required byte-identical state root.
A certified block at a height with a local in-flight round supersedes the
round. If a peer is ahead but local progress stalls, an
AppChainStalledEvent fires (visible in status as stalled).
8.2 Restart semantics
Section titled “8.2 Restart semantics”Persisted (RocksDB, all atomic with their block): blocks + certs, tip metadata, MPF trie + root, message/query indexes, per-sender floors, per-view prepare locks, prepared certificates + canonical proposal envelopes, membership epochs and pending governance. In-memory (lost on restart, by design): the pending pool, active round aggregation (reconstructed from persisted evidence), gossip dedup/counters, pending anchor rounds.
Startup re-verifies rather than trusts:
- snapshot manifest signatures/hashes (if restoring) — before RocksDB touches anything;
- ledger integrity: committed root equals the tip block’s recorded root;
- persisted membership epochs override static config;
- tip-cert verification: the tip block re-hashes to the stored tip hash AND its cert carries ≥ threshold valid member-at-height signatures — a snapshot whose contents merely self-agree is rejected;
- own sender-seq floor restored so a restarted member never reuses a finalized sequence number.
8.3 Snapshots
Section titled “8.3 Snapshots”GET .../snapshot produces a RocksDB checkpoint (hard links — cheap) with a
member-signed manifest binding tip height/hash, state root and member
epochs. New-member onboarding = copy checkpoint, verify manifest, start,
catch up the delta (user guide §14.3).
9. State machines — the SPI
Section titled “9. State machines — the SPI”org.yanoproject.api.appchain.AppStateMachine:
| Method | When | Contract |
|---|---|---|
id() |
— | stable identifier, matched against state-machine config |
init(reader, info) |
once at start | read-only warm-up; info = (chainId, own member key, member count) |
validate(msg) |
admission (pool + block selection) | fast, side-effect-free, MAY run concurrently; envelope auth already done; reject keeps the message out of blocks. ~ system topics bypass it |
apply(block, writer) |
exactly once per finalized block, in height order, on EVERY member | the deterministic transition; all writes via writer.put/delete (= MPF entries) |
query(path, params) |
committed reads | invoked through the chain-scoped REST /query/{path} route; state proofs remain available through state/proof/{keyHex} |
Determinism is the contract. Inside apply(): no wall clock (use
block.timestamp()), no randomness, no I/O, no environment reads, no
iteration over unordered collections, no locale-dependent serialization.
Violations don’t corrupt anything — they stall the chain, because
followers reject the proposer’s state root (§4.2). Test with
StateMachineConformance (runtime testkit): it applies an identical seeded
block corpus in N independent runs plus a kill-and-reopen replay and asserts
byte-identical roots at every height.
The two-tier rule (worth internalizing): validate rejects; apply
never does. Anything that reaches apply is already consensus-final, so a
business-rule violation there must be a silent, deterministic no-op —
re-check every rule you checked at admission. Every conforming state machine
must follow this pattern.
Convenience: TypedAppStateMachine<T> + MessageCodec<T> decode bodies at
the edge (undecodable → reject at admission / skip at apply);
JacksonCborCodec.of(Class) is the batteries-included codec.
10. Built-in state machine
Section titled “10. Built-in state machine”| id | Module | One-liner |
|---|---|---|
ordered-log |
runtime (built-in) | append-only log of opaque messages; per-message inclusion proofs |
10.1 ordered-log
Section titled “10.1 ordered-log”The default. Bodies are fully opaque — nothing is validated, everything
finalizes. Per message it writes
sha256("~yano/finalized-message/v1/" || messageId) → cbor([schemaVersion, height, index, topic, sender]) and maintains a namespaced
tip record. Resolve the public message ID through the finalized-message-v1
typed proof subject; state/proof/{keyHex} is the lower-level route for the
resolved physical key. The record format is shared (FinalizedMessageIndex in
core-api) with opt-in indexes so proofs never diverge.
Additional state machines are ServiceLoader plugins maintained in Yano X. Their wire formats, typed clients, proofs, and operational guidance live with those plugins so Yano’s core documentation does not imply that they are built in.
11. Writing your own state machine
Section titled “11. Writing your own state machine”-
Implement
AppStateMachine(or extendTypedAppStateMachine<T>), obey §9’s determinism rules and the two-tier validate/no-op pattern. -
Implement
AppStateMachineProvider—id()matches thestate-machineconfig value; overridecreate(AppStateMachineContext)if you need settings (context.settings()is theyano.app-chain.*map with the stem stripped, e.g.machines.my-machine.foo). -
Plugin mode (default distribution, no rebuild): register the provider in
META-INF/services/org.yanoproject.api.appchain.AppStateMachineProvider, and addMETA-INF/yano/plugins/<bundle-id>.json. The manifest declares anapp-state-machinecontribution whosenameequals the providerid()and whoseprovideris the same fully-qualified class as the ServiceLoader entry. For example:{"schemaVersion": 1,"id": "com.example.my-machine","version": "1.0.0","yanoApi": { "min": 1, "max": 1, "minLevel": 1 },"dependencies": [],"contributions": [{"kind": "app-state-machine","name": "my-machine","provider": "com.example.MyMachineProvider"}]}Package one self-contained bundle JAR, drop it into the JVM node’s
plugins/directory, and setstate-machine: my-machine. An unknown id fails fast listing available ids. Native images cannot load directory JARs; include and map the manifested bundle at application build time so catalog and reflection metadata are generated before the native executable. -
Library mode: pass the machine instance straight to the
AppChainSubsystemconstructor — no provider or services file needed. -
Start from
scaffolds/plugin-template/(a complete counter machine + provider + ServiceLoader entry + bundle manifest), and gate your machine withStateMachineConformancebefore trusting it with a multi-node chain. The full walkthrough is user guide §6 / tutorial Part 2.
12. Where to go deeper
Section titled “12. Where to go deeper”- Anchoring internals (thread NFT, co-sign rounds, on-chain validator, independent verification): user guide §5 and the implementation.
- Rotation & governed membership design: the implementation,
008.3-*. - Wire ABIs (anchor datum, evidence bundle, observations):
core-api/src/main/cddl/appchain/*.cddl. - Live regressions that exercise everything in this guide on a real
devnet: the
test-app-chain-*skills under.claude/skills/.