Skip to content

What it checks

chobo check reads a book and says two things: the diagnostics, where an error stops the book and a warning does not, and, for a book without errors, what each operation of each transfer can be refused with. What it says about calling the book comes with the operations that get there, which it ran in the reference interpreter before saying so; when it cannot find such operations, it says nothing.

What each operation can be refused with

The points example keeps each member's points between 0 and a million:

$ chobo check examples/points/points.book
examples/points/points.book: ok
  earn.hold   may be refused: points_cap (points(member) at most 1000000), key_conflict, already_refused
  earn.post   may be refused: key_conflict, already_voided, over_hold, no_such_hold
  earn.void   may be refused: already_posted, no_such_hold
  spend.hold  may be refused: not_enough_points (points(member) at least 0), key_conflict, already_refused
  spend.post  may be refused: key_conflict, already_voided, expired, over_hold, no_such_hold
  spend.void  may be refused: already_posted, expired, no_such_hold
  lapse.do    may be refused: not_enough_points (points(member) at least 0), key_conflict, already_refused
…

The reasons that can be there come from the book: the lower bound of an account a move takes from, and the upper bound of one it puts into; key_conflict, when two calls with the same key can differ in an amount or in another parameter left out of the key; already_refused, when a bound can refuse a call, which spends its key; same_account, when a move goes between two accounts of one kind; and the states of a hold. Each is listed only when the checker finds operations that get there, and runs them. A purchase's points never expire, so earn is never refused with expired; earning only puts in, so it is never refused with not_enough_points.

--format json gives those operations, as steps of a scenario with what each answered. Points are refused with points_cap when a member has a million already:

$ chobo check examples/points/points.book --format json
{
  "v": 2,
  "files": [
    {
      "file": "examples/points/points.book",
      "ok": true,
      "diagnostics": [],
      "report": [
        {
          "kind": "earn",
          "op": "hold",
          "refusals": [
            {
              "name": "points_cap",
              "because": "bound",
              "spends_key": true,
              "example": [
                {
                  "op": "hold",
                  "kind": "earn",
                  "args": {
                    "purchase": "purchase-1",
                    "member": "member-2",
                    "pts": 1000000
                  },
                  "result": "done"
                },
                {
                  "op": "post",
                  "kind": "earn",
                  "args": {
                    "purchase": "purchase-1"
                  },
                  "result": "done"
                },
                {
                  "op": "hold",
                  "kind": "earn",
                  "args": {
                    "purchase": "purchase-3",
                    "member": "member-2",
                    "pts": 1
                  },
                  "result": "refused",
                  "reason": "points_cap"
                }
              ],
…

The list is what a workflow's task that runs the operation declares as its errors, and what chobo api gives another tool, with the same examples (Formats). The search behind it gives an amount 1, 2, 3, 5, 10, 100 or 1000, and to fill an account it takes from, it calls a transfer that puts into it, three deep at most. A reason only an amount of 0 reaches, or one deeper than that, is not listed.

Diagnostics

A diagnostic names its code, the line, and what is wrong. An error stops the book: nothing is run, built or drawn until it is fixed, and after a line that does not read, nothing else is checked, since anything after would be a guess. Most errors are about how the book is written:

error[E030]: keys.book:9:10: the transfer `receive` has no `key` line; every transfer needs an idempotency key. List the parameters it happens once for, like `key order`
     9 | transfer receive(slip: string, sku: string, qty: item)
error[E031]: keys.book:12:14: the amount `qty` cannot be part of the key: two calls that differ only in the amount would both go through, as two transfers
    12 |   key order, qty
error[E030]: keys.book:15:7: `slip_no` in the key is not a parameter of `take_back`
    15 |   key slip_no
Codes What they find
E001 to E005 a line that does not read, a name not declared or declared twice, the number of an account's arguments, a type
E010, E011, E014 a move between units, a number its unit cannot hold, a tax on a unit that is not money
E012, E013 a move from an account to the same account whatever the call, a transfer without a move
E020 to E023 an account of the book without a bound, an outside one with one, bounds no balance fits, a bound without its reason
E030, E031 a transfer without a key, an amount in the key
E040, E041 a hold that does not say how it ends, an expiry out of range

Four warnings are about what happens when the book is called, and come with the operations that get there, which the checker ran. A transfer whose moves are in an order that can refuse it, when the two moves together would leave enough (tests/fixtures/split.book):

warning[W103]: split.book:13:3: move 1 takes from shop_balance(shop) before move 2 puts into it: when shop_balance(shop) is short at that point, the call is refused with insufficient_balance, even when the two moves together would leave enough
    13 |   move fee from shop_balance(shop) to fees
  the operations that get there:
       1  sale.do(order: order-1, shop: shop-2, price: 1, fee: 1)  refused: insufficient_balance (move 1 takes 1 from shop_balance(shop-2): posted 0, held out 0)
  hint: write the move that puts into shop_balance(shop) first

In a hold, what one move puts in is held coming in, and another move of the same hold cannot take it, in whatever order they are written:

warning[W104]: hold_order.book:14:3: in a hold, what move 1 puts into held(order) is held coming in, and does not count when move 2 takes from held(order): unless held(order) has enough already, the hold is refused with held_short
    14 |   move price from held(order) to shop_payout
  the operations that get there:
       1  settle.hold(order: order-1, price: 1)  refused: held_short (move 2 takes 1 from held(order-1): posted 0, held out 0, held in 1)
  hint: have what it takes in held(order) beforehand, or take it in another transfer after the hold is posted
Codes What they find
W101 an account that only ever fills: no move takes out of it
W102 a transfer always refused: nothing puts into what it takes from
W103 a transfer refused for the order of its moves
W104 a move of a hold that counts on what another move of the same hold puts in
W105, W106 something never used, a bound that never matters
W107 with --diff-base, an account or a transfer gone since the revision compared with (below)
W901 a key written in the file, in a string or a comment: the diagnostic gives its kind, its prefix and its length, never the key

Four errors belong to a target, and chobo build gives them: a chain of transfers too long for one TigerBeetle request (E060, at 253); a name longer than PostgreSQL's 63 bytes (E061), which PostgreSQL would cut short without a word; a book's name the MySQL family's tables and checks cannot be named after (E062), longer than 51 characters or with a character beyond the Basic Multilingual Plane, which MySQL would write as ? with only a warning; and, for the Rust targets, a name with a character no Rust identifier holds where the name has it (E063), such as a digit that is not decimal, which the Rust client would write as it is, as a field of a struct:

error[E063]: identifiers.book:11:46: The parameter's name `area_m²` has ² (U+00B2), which no Rust identifier holds
    11 | transfer build(permit: string, site: string, area_m²: sqm)

A book is held only to the limits of the target it is built for.

Diagnostics has all 32 codes, each with when it appears, how to fix it and the smallest book that shows it, as chobo explain --all prints them; chobo explain <code> prints one. Every diagnostic of chobo's on this site is taken from a golden file of chobo's tests, which hold the diagnostics of each test book, in English and in Japanese, to what the checker prints.

Changes the data cannot follow

A book's version is not part of what an account or a transfer is, so the balances and the holds a database has go on under a new version. chobo check --diff-base <revision> compares the book with the one at that git revision, and refuses what they could not follow. The book of tests/fixtures/changes.before.book keeps stock at 0 or more, and keys a delivery on its slip and its SKU:

book changes v1
…
account stock(sku: string) : item
  at least 0 refused as out_of_stock
…
transfer receive(slip: string, sku: string, qty: item)
  key slip, sku
  move qty from supplier to stock(sku)
transfer reserve(order: string, sku: string, qty: item)
  key order, sku
  pending expires after 30 minutes

Its next version keeps 3 on the shelf and at most 1000, keys a delivery on its slip alone, holds an order's stock for an hour, and drops the shelf of returns with the two transfers that used it:

book changes v2
…
account stock(sku: string) : item
  at least 3 refused as out_of_stock
  at most 1000 refused as no_room
…
transfer receive(slip: string, sku: string, qty: item)
  key slip
…
  pending expires after 1 hour
warning[W107]: changes.book:1:1: the account `returns_shelf` of HEAD is gone: its balances stay in the database
     1 | book changes v2
warning[W107]: changes.book:1:1: the transfer `take_back` of HEAD is gone: a retry of it still in flight can no longer be made
     1 | book changes v2
warning[W107]: changes.book:1:1: the transfer `restock` of HEAD is gone: a retry of it still in flight can no longer be made
     1 | book changes v2
error[E050]: changes.book:5:9: the account `stock` differs from HEAD in its lower bound (0 → 3), its upper bound (none → 1000): the accounts that already exist keep the old definition, so chobo cannot make this change
     5 | account stock(sku: string) : item
  hint: declare an account under a new name, and write a transfer that moves the balances over
error[E051]: changes.book:11:10: the transfer `receive` differs from HEAD in its key: a retry in flight would be refused with key_conflict
    11 | transfer receive(slip: string, sku: string, qty: item)
  hint: declare the new form under a new name, and keep the old one until every hold it made has ended
error[E051]: changes.book:14:10: the transfer `reserve` differs from HEAD in how its holds end: a retry in flight would be refused with key_conflict, and a hold still held could not be posted
    14 | transfer reserve(order: string, sku: string, qty: item)
  hint: declare the new form under a new name, and keep the old one until every hold it made has ended

The databases hold the same line when the book is called. PostgreSQL and the MySQL family keep the bounds an account was made with in its row, and stop a call whose book says other bounds with the error CB001. TigerBeetle's client finds the account, or the opening transfer that keeps its bound, already there with another definition, and fails. To change a bound, declare an account kind under a new name and write a transfer that moves the balance over; a limit that will change is an account of its own from the start.

Across the languages

In a project with workflows and rules, ritsu check checks the book where a workflow calls it: the amounts a rule hands to a transfer, the reasons the transfer can be refused with against the errors the task handles, and a hold against the wait before it is posted. With the other languages shows each.