A production-grade declarative state-machine engine, demonstrated by a realistic payment lifecycle -- durable per-state timeouts and crash-recovery via a transactional outbox are the central engineering problems, not an afterthought.
Nobody has started this yet — be first.
Creating a ₹0 payment returns 500 instead of 422
Any client (a checkout page with a broken discount calculation, a batch import script with a bad row) that ever sends amount_minor: 0 gets back a 500 Internal Server Error. Most HTTP clients and retry middleware treat 5xx as "transient, safe to retry" and 4xx as "your request is wrong, fix it and don't retry blindly" -- so this single boundary bug turns one bad request into a retry storm against an endpoint that will never succeed for that payload, and it pollutes server-error-rate dashboards/alerts with noise that isn't actually a server problem. Anyone on call investigating an "error rate spike" alert will burn time confirming it's just a client sending zero-amount requests.
About this project
A standalone, payment-agnostic declarative state-machine library (pkg/statemachine) with one concrete application built on top of it: a realistic payment lifecycle (created -> authorized -> captured -> settled -> charged_back, plus failed/expired/voided/refunded) driven entirely by a declarative transition table -- there is no if/switch anywhere else in the codebase deciding what a payment is allowed to do next.
Two engineering problems sit at the center, not the edges: durable per-state timeouts (a UPI-collect payment auto-expires after 5 minutes with no human, webhook, or poll involved, via a SKIP LOCKED sweeper that fires a due timeout exactly once even under many concurrently racing replicas) and crash recovery via a transactional outbox (every side effect owed to the bank/UPI rail is durably queued in the same transaction as the state change, then dispatched by a separate background process with jittered-backoff retry and idempotency keys, so a process kill mid-flight loses nothing). Optimistic concurrency -- a single conditional -- is what actually serializes concurrent writers; there is no anywhere in the payment write path.
Builder.Timeout accepts a zero-duration timeout
This is in the reusable state-machine library, so it affects every future caller, not just payments. A zero-duration Timeout schedules a deadline of "right now" -- the sweeper could claim and fire it before the very transaction that created it has even returned a response to the caller. For a payment machine specifically, imagine someone later adds Timeout("some_state", cfg.SomeDuration, "give_up") and SomeDuration comes from a misconfigured env var that parses to 0 -- instead of a clear startup error ("duration must be positive"), the app starts fine and then behaves nondeterministically in production, which is a much more expensive class of bug to diagnose.
UPDATE ... WHERE version = $expectedVersionSELECT ... FOR UPDATETwenty-five tickets are deliberately injected into an otherwise fully-verified, Docker-proven codebase: some are one-character bugs (a flipped comparison operator, a typo'd env var name, swapped constants), others are subtle concurrency and correctness bugs that only a deterministic, non-timing-dependent test can reliably catch (a stale-version check that always evaluates true, a missing FOR UPDATE SKIP LOCKED), and the rest are enhancements with a compile-only stub so the codebase and its verification tests build before the real implementation is written. Every ticket has a dedicated Go test that goes green when the fix is correct.
docker composepaystate/paystate/go.modreplace reliable => ../reliabledocker compose up --buildpaystate/context: ..docker-compose.ymlCREATED_TIMEOUTAUTHORIZED_TIMEOUTWork the tickets in tickets/docs/ (ticket-01 through ticket-25); each names one Go test in tickets/tests/:
TEST_DATABASE_URL=postgresql://... go test ./tickets/tests/... -run TestTicket01 -v # a single ticket
TEST_DATABASE_URL=postgresql://... ./tickets/run_tickets.sh # all 25, clean pass/fail summary
Tickets that don't touch Postgres (02, 03, 04, 05, 06, 14, 15, 24) run fine without TEST_DATABASE_URL set; the rest report FAIL with a "TEST_DATABASE_URL not set" message if it's missing. make test-race runs the full go test -race -p 1 ./... suite the same way.
How to run it locally, step by step.