@kairos/domain
Money, ids, time, rails. Paise as a branded integer, so a rupee float cannot reach a budget.
depends on: nothingKairos catches a failing payment method in ninety seconds, steers live checkouts off it, and recovers the customers already lost. Four questions first.
A payment-health defence that runs for one merchant, under a mandate that merchant signed — a sealed document setting out exactly what it may do with their money, and what it may not.
A payment method is one way to pay through one bank — netbanking through HDFC, UPI through SBI, a card on one network. A busy merchant offers dozens of them, and any single one can start failing on its own while every other one looks perfectly healthy.
The left column is what a mandate grants. The right is what it withholds — and the right column is the product.
A bank's payment method starts failing. One payment in three.
Customers tap Pay, get an error, and leave.
Nobody notices for forty minutes.
Kairos sees it in 93 seconds.
Which was never the hard part.
Three separate pieces of money — and only one of them is the one every recovery tool talks about.
The range is two different incidents: UPI through one bank failing 42% of its attempts, and netbanking through another failing 55%. The worse a method is behaving, the more there is to save.
Counted as incremental: what came back because of the system, on top of what would have come back on its own. Most dunning numbers are gross, which makes them roughly five times larger and not a measurement.
Against that schedule: 17% more money on 46% fewer messages. An opt-out is permanent, so the channel is an asset you spend, and almost nothing prices it.
The obvious alternative doesn't just overspend. It messages one customer fourteen times.
| same budget · 64 workers running at once | Kairos | a plain if (spent < budget) check |
|---|---|---|
| of the budget, spent | 99.6% | 120% |
| over budget | nothing | one fifth of it again |
| times it broke the contact cap | 0 | 271 |
| most messages to any one person, against a cap of 3 | 3 | 14 |
A merchant can leave this running because it cannot spend their money twice or burn a customer they will need next month. That is the whole economic unlock: no approval queue. An approval queue is what makes every other version of this too expensive to operate, because it needs a person who is awake.
Moves customers off the failing method before they ever see an error.
Knows why each payment died. An expired card needs a fix-link, not a retry.
468 message variants in four languages, written once per situation.
How do I know it won't overspend?
₹500 budget. 64 workers at once.
₹0 over.
A plain budget check spends ₹600.
How do I know it actually helped?
A holdout is always kept back.
Counted as incremental, never gross.
17% more money on half the messages.
How do I know what it did?
Every action carries signed authority.
Every refusal names the bound.
No figure without a record.
What don't you know?
Every figure here is a seeded simulation, not production traffic.
The copy advantage rests on a readability weight nobody has measured.
One band is wider than the value it guards.
All three are on the scorecard, not in a footnote.
Chronos is clock time. Kairos is the moment that still works.
Every dunning system in the world retries on chronos.
The film is the argument. The system itself is next door — a recorded run of the real detector, kernel and ledger, steppable a decision at a time: open the demo →
1080p60 · H.264 · 40 MB · download this cut. Mastered at 3840×2160, which is too large to serve from here — the 4K master is on Google Drive.
Kairos is a backend service with no interface of its own. It sits behind a checkout somebody else built and works a queue nobody watches, so there is no product screen to show you and this page is not a screenshot of one. It is a worked simulation: the real detector, kernel and ledger, replayed from a recorded run and drawn out so decisions taken in milliseconds can be read at the speed a person reads.
One payment method starts failing. Here is what Kairos does about it, a step at a time — and, at the end, what it refuses to do. Every number on this page comes out of that run. Nothing here is computed to make a point.
Rail health, open incidents, which bound is binding, and the audit trail — the dense view, for reading during an incident rather than for learning the system.
The bar under each rail is the detector's running statistic against its threshold. The simulator knows the ground truth; this panel deliberately does not show it, because an operator will never have that comparison.
Not driven by this run. The recovery arm is measured in the benchmark, where it recovers ₹6,82,069 — but the demo does not yet step it, so contact-cap and campaign-budget never bind here. That gap is why budget-exhaustion below never exhausts its ₹20: a steer reserves ₹3 and reconciles to zero.
One incident, from the minute a payment method starts failing to the minute the ledger closes on it. Everything below happens without anybody approving anything — which is why the bounds matter more than the capabilities.
Two of those steps are the whole argument. At 14:16 the money was committed before it was spent, and at 15:30 something the system wanted to do simply did not happen. Scrub a real run of this →
A model writes the message library: 468 validated variants across four languages, one call per situation rather than per customer. 180 calls in total. The output is committed to the repository and reviewed as a diff, like any other code.
It also writes the operator's explanations — and refuses to publish one if any figure in it does not appear, character for character, in an audit record.
At runtime, choosing what to send is a lookup, not a call. Nothing on the path where money moves waits on a provider, a quota, or a sampled token.
A model you cannot reach at 14:14 cannot be prompt-injected, cannot rate-limit you during the incident, and cannot invent a refund amount. That is not caution about AI; it is where this particular tool is worth its latency and where it is not.
Nothing skips Terminus. The policy layer proposes; only the kernel can authorise, and it writes the record either way — an allowed action and a refusal cost the same to audit.
◀ DRAG ▶ · FOURTEEN OF THEM, NONE OF THE CORE ONES IMPORTING A NETWORK CLIENT
Money, ids, time, rails. Paise as a branded integer, so a rupee float cannot reach a budget.
depends on: nothingOne-sided CUSUM changepoint over a method's failure rate, with a frozen baseline and a rollup across slices.
depends on: domainThe kernel. Sealed mandates, reservations, reconciliation, and a refusal that names its binding axis.
depends on: domainProposes steers. Declines when the failing method is no worse than where customers would land.
depends on: domain, detectClassifies why each payment died and picks retry, fix-link or message accordingly.
depends on: domain, terminus, reasonThe amortised copy library and the honesty check that verifies every figure against a record.
depends on: domainRetrieve, redact, describe the bounds, ask — and refuse rather than caption.
depends on: domain, reasonHash-chained audit records, verified on read rather than remembered.
depends on: domainSeeded traffic, rail degradations, a customer population with a language mix.
test surface onlyThe real ports: Checkout ordering, messaging, retries. Template copy lives here as the control arm.
depends on: domainFive arms, eight seeds, regression bands. The gate CI runs.
depends on: everythingThe read model behind the demo, plus explain.
The casualty queue, durable. An atomic lease is what turns the worker from one instance into a fleet.
depends on: domain, recoverA form that authors a mandate and a command that signs it — deliberately two programs, so the key stays where it is.
depends on: domain, recover, terminus# the console, against simulated traffic $ export KAIROS_MANDATE_SECRET=$(openssl rand -hex 32) $ pnpm --filter @kairos/console run start $ curl localhost:8788/api/scenarios $ curl -X POST localhost:8788/api/step \ -H 'content-type: application/json' \ -d '{"ticks":20}'
The client drives the clock rather than the server running a timer, so an incident can be stepped through by hand while recording.
# why did it treat this one that way? $ pnpm --filter @kairos/console run explain -- --list $ pnpm --filter @kairos/console run explain -- cas_9f21 # the benchmark gate CI runs $ pnpm bench:gate
explain prints the audit records with or without a model. Prose is added only if one is configured and every figure in it came from a record.
468 message variants are written once, offline, for ₹8.15 and committed as a reviewed diff. At runtime it is a lookup, not a call.
Every commit builds, lints, typechecks and tests standalone — verified in a clean worktree, not just at HEAD.
Regression bands come from an eight-seed study. A change that moves a figure outside its band does not merge.
Kairos is a service you run beside the gateway you already have. It never sits in the payment path: it answers one question before the page renders, reads the outcomes you already receive, and works the casualties afterwards. Everything below runs offline, with no API key, and sends nothing to anybody until you say so.
That is the whole toolchain. The benchmark is seeded and offline and the language-model adapter replays a committed recording, so nothing here needs a key to run or to test.
The Razorpay adapter ships, and its client and inbound webhook have both been run
against the live API in test mode. Gateway and Messenger are
ports — one method each, charge and send — so
another gateway is an adapter rather than a fork. Nothing in the core imports a network
client.
Without it the recovery worker is a single instance holding its own queue. With it, the queue and the spend authority both live in the database and the same command is a fleet.
$ git clone https://github.com/AmeyaBorkar/kairos $ cd kairos && pnpm install && pnpm build # the whole gate: lint, deps, types, 1,099 tests $ pnpm check # the signing key. there is no development default — # a mandate signed with a known key is one anyone can forge $ export KAIROS_MANDATE_SECRET=$(openssl rand -hex 32) $ pnpm --filter @kairos/sentry start → listening on :8080
Two processes, and they do not talk to each other.
sentry is stateless and horizontally scalable: it ingests outcomes, runs
detection, and answers the checkout. recover-worker drains the casualty queue.
They share a mandate and a ledger, not a connection.
The benchmark, the operator console and the copy library all run from the same checkout with no credentials at all — which is the point of keeping every outside system behind a port.
One call, on the page you already have,
before you render the method list. It answers in Kairos vocabulary and as the
config object Razorpay Checkout takes, so a merchant on another gateway needs no
translation layer — and neither reading is derived from the other, because that derivation is
where a translation bug would live.
# request $ curl -X POST localhost:8080/plan \ -H 'content-type: application/json' \ -d '{"customer":"cus_9f3b2a71c4e8d012", "sequence":["upi","card","netbanking"]}'
# response
{ "sequence": ["upi","card","netbanking"],
"suppress": [], "demote": [],
"steered": false, "arm": "treated",
"checkout": { "display": { … } },
"maxAgeMs": 15000,
"reason": "nothing in force" }
| field | what to do with it |
|---|---|
sequence | Render the methods in this order. Exhaustive over what you offered. |
suppress | Instruments to leave off the page entirely — a specific bank, a specific card issuer. |
demote | Methods pushed down the list. Nothing is removed. |
checkout | The same decision as Razorpay's config. Pass it through unchanged. |
arm | Echo this back on the outcome. It is how the holdout stays honest at an integration that has never heard of one. |
steered | False means this response changes nothing — including when Kairos failed and declined to guess. |
maxAgeMs | How long the answer keeps. Anything fresher is a request the service did not need to answer. |
reason | Why it says what it says, in words. Always present. Log it. |
It always answers 200. A body that does not parse
gets your own ordering back with the offending field named in reason — visible on
the first call an integrator makes, and harmless to the customer waiting on the page. The
failure mode of a payment-health tool must never be "no payments".
$ curl -X POST localhost:8080/outcomes \ -H 'content-type: application/json' \ -d '{"attempts":[{ "id":"pay_1", "orderId":"order_1", "customer":"cus_9f3b2a71c4e8d012", "amountPaise":120000, "method":"upi", "issuer":"hdfc", "status":"failed", "at":1756900000000, "arm":"treated" }]}' → {"accepted":1,"opened":0,"incidents":0}
The same events your webhook already delivers, batched up to a thousand at a time. Unlike the plan route this one rejects what it cannot parse rather than coercing it: an outcome of the wrong shape is evidence about the integration, not about the rails, and quietly accepting it would put a slice in the detector that nothing else can reason about.
customer must be at least sixteen characters. That is a
structural guard rather than a formality — a keyed hash always clears it and a bare phone
number never does, so a raw identifier cannot reach the detector, the ledger or a model
prompt by accident.
# Dashboard → Settings → Webhooks $ RAZORPAY_WEBHOOK_SECRET=… \ LEDGER_PII_HASH_KEY=… \ pnpm --filter @kairos/sentry start POST /webhooks/razorpay → {"event":"payment.failed", "method":"card","status":"failed", "failure":{ "code":"BAD_REQUEST_ERROR", "source":"business", "step":"payment_initiation", "reason":"international_ transaction_not_allowed"}}
Report nothing at all: point the dashboard at Kairos
and the gateway delivers. Three things must be true before a delivery changes any state —
the signature verifies against the raw bytes, the event is recent, and it is not one
already handled. Anything failing one of them is answered 200 with the reason
in the body, because a gateway retries a non-2xx for hours and a bad signature does not
become good on the fourth attempt.
What the gateway says about the failure is passed through
untranslated. source, step and
reason are the triple the classifier reads, and re-coding them into a private
enum would put a lossy mapping between the gateway's account of what went wrong and the
decision made about it.
A payment carries email and contact. The
translation takes a pseudonymiser as a required argument, so a phone number cannot
reach the detector by omission — the reference above is a keyed hash and there is nowhere
else the raw value goes.
# decides everything, sends nothing $ KAIROS_MERCHANT_ID=acme \ KAIROS_LINK_BASE=https://pay.acme.test \ pnpm --filter @kairos/recover-worker start # one instance → a fleet $ export KAIROS_DATABASE_URL=postgres://… $ pnpm --filter @kairos/postgres run schema \ | psql "$KAIROS_DATABASE_URL"
Dry-run delivery is the default and live is opt-in, because getting that backwards is unrecoverable: a message sent by mistake cannot be unsent, and a merchant who meant to observe for a week and instead messaged their customers has a problem no rollback fixes. Every decision is real; nothing leaves the process.
With a database, two things move out of memory and they are not the same protection. Authority stops two workers double-spending. The queue's atomic lease stops them double-sending — idempotent authority hands both workers the same grant for the same casualty.
A signed mandate is unreadable by design: paise and epoch milliseconds, and the two numbers that matter most are not in it but are products of numbers that are. Nobody authorising spend can be asked to do that arithmetic in their head, so the tool does it and prints it before it signs.
# author one in a browser, on loopback — or without one $ pnpm --filter @kairos/mandate start form $ kairos-mandate seal spec.json > mandate.json # reading to stderr, mandate to stdout MONEY Ceiling for the whole campaign ₹5,000.00 Most one action may cost ₹0.60 Most that can be committed at once ₹9.60 (16 actions in flight) Actions the budget buys, worst case 8,333 PEOPLE At most 3 messages to one person per 7 days. Nothing sent between 21:00 and 08:00 at UTC+05:30. SIGNATURE bb498b0eeb882107c7a1…f063820eec3c6 Verified. Every field above is covered by this signature and none of them has been altered.
The page collects a spec — rupees, days, clock times — and posts it back for conversion, so there is exactly one implementation of "₹5,000 for 30 days" and the form calls it rather than re-deriving it. It never holds the signing key: the process serving it seals, or says it cannot and refuses. Loopback only, and not configurable.
| variable | default | what it decides |
|---|---|---|
KAIROS_MANDATE_SECRET | — | Required, ≥32 chars. Refused rather than defaulted: a development fallback here is a key that ships to production the first time somebody forgets a variable. |
KAIROS_MERCHANT_ID | — | Required by the worker. Namespaces the budget and the contact caps. |
KAIROS_DATABASE_URL | unset | Unset is a single instance. Set is a fleet. That is the whole difference. |
KAIROS_DELIVERY | dry-run | Anything else needs a live gateway and a DLT-registered sender, and refuses to start without them. |
KAIROS_BUDGET_PAISE | 100000 | The campaign's lifetime ceiling — ₹1,000. Not a rate. |
KAIROS_MAX_IN_FLIGHT | 16 | Blast radius. Granted by the mandate, so adding workers cannot raise it. |
KAIROS_CONTACT_LIMIT | 3 | Messages to one person per window, across every casualty of theirs. |
KAIROS_CONTACT_WINDOW_MS | 7 days | Rolling, not calendar — a calendar week allows six messages in twelve hours. |
KAIROS_RESERVATION_TTL_MS | 120000 | How long authority survives unreconciled. Must outlast the slowest action. |
KAIROS_DRAIN_INTERVAL_MS | 15000 | How often the worker looks at the queue. |
KAIROS_DB_MIGRATE | on | off hands the schema to a deployment that owns its own migrations. |
PORT | 8080 | Where sentry listens. |
Quiet hours are not configurable. 21:00–08:00 IST, and a merchant who wants to message people at three in the morning wants something Kairos does not do.
Every one of these is a code path with a test, not an intention. The rule underneath all of them: a request that cannot be evaluated must not be acted on, and a checkout that cannot be advised must still render.
| when | what happens |
|---|---|
| Kairos is unreachable, or slow | Your checkout renders your own ordering. That is what it would have done without any of this. |
| A plan takes longer than 50 ms | Same. The budget is measured, not hoped for, and blowing it is a fallback rather than a wait. |
| A customer reference does not parse | Same, with the fault named in reason. Still a 200. |
| The shared store cannot be read | The contact allowance reads zero. A policy that cannot tell how much it has already spoken to somebody assumes it has spoken to them enough. |
| The kill switch cannot be read | Counted as engaged. Not knowing whether you have been told to stop is being told to stop. |
| The audit sink fails | The admission is refused. A decision that cannot be recorded is not taken. |
| A mandate does not verify | Every action refused by name — including one whose budget was raised after signing. |
| A worker dies mid-action | The reservation expires and its authority returns to the pool; the lease expires and the next pass picks the casualty up unchanged. Nothing leaks and nothing is sent twice. |
| Either kill switch is thrown | Everything stops. The signed one cannot be cleared by anyone who cannot sign; the store-backed one propagates fleet-wide within one admission with no redeploy. |
The recovery arm needs a Razorpay account able to charge saved tokens and a DLT-registered sender. It refuses to start pretending otherwise.
A fleet shares its queue and its budget but not its ledger. Every record is still written and still tamper-evident; "show me everything" means interleaving N chains.
A sealed mandate is a file somebody carries to a worker. There is no control plane that hands one out, rotates it, or revokes it short of the kill switch.
The full list is eighteen entries long and lives in
docs/ARCHITECTURE.md under honest gaps, next to the things that do work.
And the numbers →
Every figure here was produced by a seeded benchmark anybody can re-run, and carries a regression band derived from an eight-seed variance study. Nothing on this page is an estimate.
Measuring from the alarm rather than the changepoint would make any detector look instant. It is the single most common way this number is inflated.
The false-alarm rate rests on two observed alarms across fourteen hours of healthy traffic, so the interval around it is wide. Read it as roughly one an evening, not as two decimal places.
A budget check is if (spent + cost > budget) return. That is a rule, and rules race: two workers read the same number and both spend it. Terminus reserves before acting and reconciles against what actually happened — the software version of a locking bar that jams the conflicting lever. Not ₹524. Not "about ₹500."
Every arm ran against the same simulated merchant: four hours of traffic, 300 payment attempts a minute, a pool of 12,000 customers, with a netbanking rail and a UPI rail degraded partway through. The rupee totals below are only meaningful against each other — they belong to that population and to no real merchant.
| arm | incremental | messages | consent lost | ₹ per message |
|---|---|---|---|---|
| do nothing | — | 0 | 0 | — |
| message everyone | ₹1,40,736 | 5,544 | 67 | ₹25 |
| fixed ladder | ₹5,84,006 | 10,681 | 132 | ₹55 |
| kairos · template copy | ₹6,13,843 | 5,817 | 58 | ₹106 |
| kairos · generated copy | ₹6,82,069 | 5,749 | 54 | ₹119 |
Incremental — gross, minus what came back unaided. The fixed ladder recovers 14% less than Kairos while sending 1.9× as many messages and losing 2.4× the consent.
Tight enough that a real regression is visible.
Which is exactly why the generated-copy claim above is stated as a sweep and not a number.
A single-seed sweep once came out non-monotonic — a penalty of 0.25 scoring 4.6% below a penalty of 0.00, when it should have scored above. Against a measured run-to-run variation of 4.18%, that was the noise floor and nothing else. The fix was four seeds and a reported worst case, not a better-looking chart.
Every figure on this site was produced by a benchmark in the repository, against simulated payment traffic. None of it is from a live merchant. This is a Razorpay AI Buildathon project and it will not pretend otherwise.
Simulated does not mean invented. The traffic is seeded, so the same seed gives the same run on any machine; the components under test are the real detector, the real kernel and the real ledger; and the harness is in the repo for anybody who wants to disagree with it.