Dates and books
A workflow often counts days and moves quantities: it holds an order's goods until the payment is
due, ships them once paid, and puts them back when not. A .flow does neither itself, since it has
no arithmetic. The days are counted by a dates file of
koyomi (.cal), and the quantities kept by
a book of chobo (.book). A .flow reads
them as it reads rulec's rules: it calls a date as it calls a rule, and runs an operation of a book
as a task.
The invoice example does all of it, and runs as it is on every platform (examples/invoice):
use dates terms from "dates/payment_terms.cal"
lambda "arn:aws:lambda:eu-west-2:123456789012:function:payment-terms"
use book stock from "books/stock.book"
lambda "arn:aws:lambda:eu-west-2:123456789012:function:stock"
…
task reserve(order: string, sku: string, qty: int) -> stock.reserve
book stock.reserve.hold
starts stock.reserve
errors out_of_stock
…
case hold : stock.reserve follows stock.reserve
flow
hold <- reserve(order: order.id, sku: order.sku, qty: order.quantity)
on out_of_stock => succeed outcome = out_of_stock, due = none
let due = terms.payment(received: now)
wait until due.at
let payment = check_payment(order: order.id, due: due.day)
match payment.paid
true =>
hold <- ship(order: order.id, sku: order.sku)
on expired => succeed outcome = not_paid, due = due.day
succeed outcome = shipped, due = due.day
…
koyomi checks the dates file on every day of its range, and chobo checks the book's bounds and
the reasons each operation can be refused with. dandori reads what they found through ritsu's
ports, as it reads the rules: ritsu dandori reads the dates files and the books in the same
process, and the dandori binary alone says to run a workflow that uses them that way (E018).
Dates
The dates file of the example closes on the 20th and pays on the 10th of the next month at 09:00, or on the business day before:
dates payment_terms v1
description "Closes on the 20th; pays on the 10th of the next month, at 09:00, or on the business day before when that day is closed"
use calendar "../calendars/weekdays.cal"
inputs
received : date range >=2026-01-01 <=2027-12-31
date closing = received
close day 20 # "closes on the 20th"
date payment = closing
day 10 of month +1 # "pays on the 10th of the next month"
roll preceding # "on the business day before when that day is closed"
at 09:00
…
use dates <name> from "<file.cal>" reads it, and each date of the file is called by its name,
like a rule: terms.payment(received: …). It answers a record of two fields: day, the date, and,
when the date says a time of day (at 09:00), at, that time in UTC, ready for wait until. A
date that says no time answers day alone.
- A date input takes a
date. When the file's calendar says its UTC offset (offset +09:00), it takes atimestamptoo, read as the day that time falls on there:2026-03-31T15:30:00Zis April 1 at+09:00. A time given to a date whose calendar says no offset is E003, and the note says to give the calendar one, or to pass a day. - An integer input takes an
int, held to the range the file declares (E014, W104), as a rule's input is. - Each date of a file is called on its own, with the inputs it reads.
- Under
use dates,lambda "<function>"is the Lambda function that computes the dates on Step Functions and Lambda durable functions, andlocalhas Temporal call them as local activities, as for a rule.
A date is called as a rule is, in an activity (a call on each platform), although koyomi's code is a pure function. A calendar's holidays change every year: computed in the workflow's code, a date would replay otherwise on a worker whose table was updated. As with a rule, the inputs and the answer stay in the run's history.
date is a type: a day, written YYYY-MM-DD. An input, an output, a field, a task's parameter or
answer can be one, and a string can have one put in it ("{order.id} ships on {ship.day}"). It is
checked where it comes in, like every other type, and never compared or added to: counting days is
what a dates file is for.
now is the time the statement runs at, in UTC, to the second: a timestamp. It can go to a date,
to a task, to wait until, and into a string ("received at {now}"). Each platform reads it so that
a replay or a retry reads the same time again (the table below).
Books
The book of the example keeps the stock of each SKU. A delivery adds to it, and an order holds what it takes until it ships, for 60 days at most:
book stock v1
…
unit pcs
account shelf(sku: string) : pcs
…
at least 0 refused as out_of_stock
account suppliers : pcs outside
account customers : pcs outside
transfer receive(delivery: string, sku: string, qty: pcs)
key delivery, sku
move qty from suppliers to shelf(sku)
transfer reserve(order: string, sku: string, qty: pcs)
…
key order, sku
pending expires after 60 days
move qty from shelf(sku) to customers
use book <name> from "<file.book>" reads it. A task runs an operation of one of its transfers with
book <book>.<transfer>.<operation>, where the operation is chobo's own: do for a transfer done at
once, and hold, post or void for one that holds first.
- The task's parameters are the transfer's, by name: all of them for
doandhold, those of the key forpostandvoid. Apostthat takes the amounts too posts that much of the hold, and gives the rest back. An amount is anint, a whole number in the book's unit. hold,postandvoidanswer the hold: a record named after the transfer (stock.reserve), with the key's parameters and the hold'sstate.doanswers nothing.- The reasons the book refuses with are the task's errors, as they are:
errors out_of_stock,errors expired. A reason the book cannot refuse that operation with is E016. A refusal the task does not declare fails the call (failure). - A book's operation happens once for its key, so it takes no
key: done again, it answers that it was done before, which reads as done, and a retry is safe. Nor does it takerefused as(below). - Under
use book,lambda "<function>"is the Lambda function that runs the book's operations on Step Functions.
tests/flows/dates_and_books.flow
posts part of a hold:
A hold as a case
A hold has a life: it is held, then posted, voided, or expired. The book says it as a state
machine, so case <name> : <book>.<transfer> follows <book>.<transfer> makes a hold a case, and the
checker follows it as it follows a rule's machine. The task that holds says starts <book>.<transfer>,
the one that posts sends post, and the one that voids sends void (E008 otherwise).
When the transfer's holds expire, expire happens on the other side by itself: the checker counts
it before every post and void, with no external to write. A refusal is one of the book's reasons,
which differ from state to state, so the case takes no refused when. The checker says when a
reason can come back unhandled:
error[E022]: tests/fixtures/book_refusals.flow:41:1: the book may refuse `post` here with `expired` (when `押さえ` is in expired); declare `expired` in the `errors` of `出荷する`, and handle it with `on expired =>`
41 | 押さえ <- 出荷する(注文: 受注.id, sku: 受注.sku)
the run that gets there:
38 引き当てる: 押さえ starts in held
40 match 受注.急ぎ: true
`expire` happens on the other side: 押さえ held → expired
A workflow that can end with the goods still held is E020, as with any case; the invoice puts them
back in on failure.
The hold's record says what the last operation answered (held, posted or voided), never
expired: an expiry shows as a refusal with expired, and an arm for expired in a match on the
hold's state is one that is never taken (E011). After a post that failed, the book may have posted
the hold all the same, so a void there can be refused with already_posted; the invoice's
on failure takes that refusal too when it puts the goods back.
On each platform
| Step Functions | Temporal | Lambda durable functions | Argo Workflows | pydantic-graph | |
|---|---|---|---|---|---|
| a date | a Lambda Task; dandori writes the function around the Python koyomi writes | an activity around koyomi's TypeScript, Python or Go (local: a local activity) |
an invoke of the same Lambda function | the caller image, around koyomi's TypeScript | a function around koyomi's Python |
| an operation of a book | the book's Lambda Task; dandori writes the function around chobo's Python client, and makes the hold from the arguments | an activity dandori writes, through the Transport's book, on the chobo client you give it |
the same, in a step | the same, in the caller image | the same, through Deps.tasks |
now |
the time the state was entered ($states.context.State.EnteredTime, to the second) |
the workflow's clock, which a replay reads again the same | a step that reads the clock, its answer checkpointed (one operation, counted by E040) | now() in the expression of the template that computes the value, evaluated once |
Deps.clock |
What you put beside the generated code:
- The dates.
koyomi gen <file.cal> --out koyomiwrites the code of a dates file, which the generated wrappers import fromkoyomi/beside them. On Temporal in Go, each dates file is a Go module of its own (koyomi/go/<package>), which yourgo.modrequires and replaces, asrules.gosays at its top. - The books. chobo writes a client for the book (
chobo build <file.book> --target postgres-typescript,tigerbeetle-python,postgres-go, …). Give theTransportthe book value it makes, by the book's name:transport({ books: { stock: postgres(pool, { tenant }) } })in TypeScript,transport(books={"stock": postgres(connection)})in Python, andTransportOptions{Books: map[string]any{"stock": stock.Postgres(pool, tenant)}}in Go. On Step Functions, deploylambda/book_stock_handler.pywith chobo's Python client, made byhandler = make_handler(lambda: tigerbeetle(ClientSync(…))).
How it is checked
- Every platform plays every scenario against the reference interpreter, as for every other call:
a scenario answers a date with a day (and a time), and an operation of a book with done, done
before, or a refusal. Each platform's clock is set to one time, so that
nowreads the same everywhere. - The code dandori writes around koyomi's (the Lambda function,
rules.ts,rules.py,rules.go) runs with the code koyomi writes, on every input of the file's range, and must answer what koyomi says each date comes to; one input in seven goes as a time of that day instead, read at the calendar's offset. - The operations of every flow's runs go through the
Transportdandori writes in TypeScript, Python and Go, and through its Lambda function for Step Functions, to the clients chobo writes, on PostgreSQL and on TigerBeetle, and must answer what chobo's reference interpreter answers: on an empty book, on one a delivery has filled, and once more on the same book, where each operation was done before.