Validation

The rule core's five stages, their precedence, and the one Rejection every façade and store returns.

On this page
  1. What to call
  2. One rule core, five stages
  3. Precedence in practice
  4. The same rejection everywhere
  5. Master data cannot reach back
  6. Why
  7. Limits

What to call

record validates and writes in one call; a refusal is Error::Rejected with a Rejection (code(), stage()). validate lists a draft's structural violations without reading the ledger.

One rule core, five stages

Every rule lives in one pure function, the rule core: it takes the input and a read-only view of the ledger at the position the record would be appended, and returns the records to write or exactly one Rejection. It reads no clock, draws no randomness and does no I/O; no façade or store has a rule of its own.

#StageChecksCodes
1Structuralthe input alone: key, postings, positive amounts, balance per (currency, layer), labels, paths, memo, sizes, hold shape100–145
2Idempotencya used key: same content replays, other content conflicts200
3Contextthe state on the booking date: an unsealed period; accounts that exist, are leaves and open; declared currencies; policy; the master-data watermark300–311
4Referencesthe records the input names: reversal targets, holds, cleared postings, master data, the calendar400–450
5Limitsgross overflow, then balance limits500–501

A stage runs only when every earlier one has passed, and the checks within a stage run in a fixed order, so an input that breaks several rules always gets the same code. record returns the first; validate returns every structural one, in that order.

use doubleentry::prelude::*;
use doubleentry::{error::Stage, validate};

// An empty key, one posting, and therefore unbalanced.
let draft: Entry = Entry::new("", date!("2026-03-15")).debit("Assets:Cash", amount!("1.00"), EUR);
let found: Vec<u16> = validate(&draft).iter().map(Rejection::code).collect();
assert_eq!(found, [100, 101, 106]);
assert!(validate(&draft).iter().all(|r| r.stage() == Stage::Structural));

let mut books = Journal::new(LedgerId::new("example.com/acme")?);
assert_eq!(books.record(draft).unwrap_err().code(), Some(100));

Precedence in practice

  • Malformed is malformed everywhere. An unbalanced draft in a sealed period is refused as unbalanced (106), not as sealed (301).
  • A retry always resolves. Idempotency runs before every state rule, so a retry of a recorded entry replays even after its period was sealed.
use doubleentry::prelude::*;

let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 1..=2)?;
let sale = |key: &str, credit| {
    Entry::new(key, date!("2026-01-15"))
        .debit("Assets:Cash", amount!("10.00"), EUR)
        .credit("Income:Sales", credit, EUR)
};
let first = books.record(sale("sale-1", amount!("10.00")))?;
books.seal_period("2026-01")?;

assert_eq!(books.record(sale("sale-2", amount!("9.00"))).unwrap_err().code(), Some(106));
let again = books.record(sale("sale-1", amount!("10.00")))?; // a retry in the sealed month
assert!(again.replayed && again.position == first.position);
assert_eq!(books.record(sale("sale-3", amount!("10.00"))).unwrap_err().code(), Some(301));

The same rejection everywhere

Journal, Ledger and every store return the same Rejection for the same input, field for field; the conformance suite (testing) checks every code against each store.

Ledger::record hands the draft to the store, which opens its write transaction, takes the ledger's lock, runs the core and writes before it commits. The state a draft is checked against is the state it is appended to, and the public API has no pre-validated entry that could be written later.

Master data cannot reach back

A master-data change that takes effect on or before the last sealed day is refused (309). A policy must also take effect after the latest booking date already recorded (431), so it never governs an entry checked under another.

use doubleentry::prelude::*;
use doubleentry::LedgerPolicy;

let mut books = Journal::new(LedgerId::new("example.com/acme")?).as_actor("controller");
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.record(
    Entry::new("sale-1", date!("2026-03-15"))
        .debit("Assets:Cash", amount!("10.00"), EUR)
        .credit("Income:Sales", amount!("10.00"), EUR),
)?;
let policy = LedgerPolicy::new([Label::new("costcenter")?], None)?;
assert_eq!(books.set_policy(policy.clone(), date!("2026-03-15")).unwrap_err().code(), Some(431));
books.set_policy(policy, date!("2026-03-16"))?;

Why

One pure core with one ordered list of stages gives one answer, testable once and checkable against every store. Replays resolve before any business rule (TigerBeetle's result order), so a client retrying after a timeout never gets a refusal for a write that succeeded. Running the core inside the write transaction leaves no gap between check and append.

Limits

  • One rejection per write: fix it, and the next attempt may stop at a later stage.
  • A rejection is final for that input and state; only Error::Backend may succeed on retry (Errors).