Skip to content

Consensus & chain configuration

View Markdown ↗

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).


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 as block.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.
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 L1

Two 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.

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.

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.

Every member ticks every block.interval-ms; the framework computes the same leader from committed context on every node, so only that member proceeds:

  1. If a round times out, broadcast a durable signed timeout for the next view.
  2. In view 0, the configured policy selects the initial leader. In higher views, the framework selects one deterministic leader from the certified context.
  3. Select the mandatory durable L1 prefix, then ordinary messages: cap by block.max-bytes (primary; the serialized block is trimmed to fit) and block.max-messages (backstop); drop already-finalized ids, stale per-sender seqs, and messages the state machine’s validate() rejects. Reserved ~ topics bypass application admission — a state machine cannot veto governance or consensus traffic.
  4. 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:

  1. decodes as a block; height not already finalized;
  2. height is exactly tip+1 (a block from the future is deferred and retried, not dropped — the transport never re-delivers);
  3. block.proposer == envelope sender (proposer authenticity);
  4. the framework accepts this leader for the block’s height and view;
  5. prevHash equals the local tip hash;
  6. messagesRoot recomputes from the message list;
  7. serialized size ≤ block.max-bytes (counted + rejected loudly);
  8. the L1 ref is monotonic and consistent with the node’s OWN L1 view (§6.3) — mismatch rejects, “proposer slightly ahead” defers;
  9. every ~l1/* observation message re-derives from the node’s own L1 stream (fail-closed);
  10. every message: valid message-id, not expired, member-signed (membership evaluated at this height), body ≤ max-message-bytes;
  11. per-sender seqs strictly increasing above the finalized floor (when message.enforce-sender-seq is on);
  12. no conflicting prepare lock in this view and no newer durable lock;
  13. 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.

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.

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.

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.

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.

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.

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.

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.

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) in apply() 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; commitBlock writes 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.

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).

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:

  1. snapshot manifest signatures/hashes (if restoring) — before RocksDB touches anything;
  2. ledger integrity: committed root equals the tip block’s recorded root;
  3. persisted membership epochs override static config;
  4. 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;
  5. own sender-seq floor restored so a restarted member never reuses a finalized sequence number.

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).

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.

id Module One-liner
ordered-log runtime (built-in) append-only log of opaque messages; per-message inclusion proofs

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.

  1. Implement AppStateMachine (or extend TypedAppStateMachine<T>), obey §9’s determinism rules and the two-tier validate/no-op pattern.

  2. Implement AppStateMachineProviderid() matches the state-machine config value; override create(AppStateMachineContext) if you need settings (context.settings() is the yano.app-chain.* map with the stem stripped, e.g. machines.my-machine.foo).

  3. Plugin mode (default distribution, no rebuild): register the provider in META-INF/services/org.yanoproject.api.appchain.AppStateMachineProvider, and add META-INF/yano/plugins/<bundle-id>.json. The manifest declares an app-state-machine contribution whose name equals the provider id() and whose provider is 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 set state-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.

  4. Library mode: pass the machine instance straight to the AppChainSubsystem constructor — no provider or services file needed.

  5. Start from scaffolds/plugin-template/ (a complete counter machine + provider + ServiceLoader entry + bundle manifest), and gate your machine with StateMachineConformance before trusting it with a multi-node chain. The full walkthrough is user guide §6 / tutorial Part 2.

  • 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/.