Reactions, policies and timers
A reaction is a handler that runs when a fact is delivered: .on('Fact', { needs, reads, decide, examples }). Policies and sagas are just capabilities that react.
export default capability({ name: 'cancel-unfulfillable', intent: 'Policy: when stock cannot be reserved for an order that is still placed, cancel that order automatically.', sends: { CancelOrder: t.object({ orderId: t.string, reason: t.string }) },}) .on('StockReservationFailed', { needs: t.object({ orderId: t.string, reason: t.string }), reads: { // Foreign read: declares only the fields this policy relies on. `atlas check` verifies orders provides them. order: { from: 'orders', key: (fact) => fact.orderId, shape: t.object({ status: t.enum('placed', 'cancelled') }) }, }, decide({ fact, state }, d) { if (state.order?.status !== 'placed') return d.reject('order-not-placed', { orderId: fact.orderId }); return d.accept({ send: [d.send('CancelOrder', { orderId: fact.orderId, reason: `Unfulfillable: ${fact.reason}` })] }); }, examples: [ { name: 'cancels the order that could not be reserved', state: { order: { status: 'placed' } }, fact: { orderId: 'o1', reason: 'Only 1 of mug available, 2 requested' }, expect: { sends: ['CancelOrder'] } }, { name: 'skips an order that is already cancelled', state: { order: { status: 'cancelled' } }, fact: { orderId: 'o1', reason: 'short' }, expect: { rejects: 'order-not-placed' } }, ], });- Reactions and sent commands run as
SYSTEM. - A reaction’s rejection means “ignored”: it is traced, not an error, and the cursor advances.
- Reactions run after the commit (eventual consistency between capabilities). Whatever must be atomic belongs in one capability.
- Sent commands go through the outbox, so a crash between a policy and the command it sends is recovered from the owner’s cursor.
- A command only other capabilities may trigger declares
access: 'internal'and is listed in the sender’ssends(ATLAS004 checks someone handles it). - A cycle of facts and commands is reported by ATLAS011 (a warning); one that only data shows is bounded at runtime by
maxDepth.
Scheduled commands (timers)
Section titled “Scheduled commands (timers)”A decision may schedule a command for later. The entry is written in the same commit as the decision and is delivered once, to the command’s owner, as SYSTEM, when the runtime clock reaches at:
sends: { ExpireOrder: t.object({ orderId: t.string, gen: t.int }) }, // declared like a sent command// in decide:const deadline = time.add(now, hours * 3_600_000); // pure ISO arithmetic (also time.diff)return d.accept({ write: [...], schedule: [d.schedule('ExpireOrder', { orderId, gen: 1 }, deadline)] });// example: expect: { schedules: ['ExpireOrder'] }.command('ExpireOrder', { access: 'internal', ... }) // a timer is never callable by users| Rule | Why |
|---|---|
| No cancel API. The command carries what makes it valid (a generation, a status) and rejects when stale; re-arming schedules a new entry with the next generation. | Cancelling races with firing; a timer that validates itself is idempotent. |
| Due entries are released into the outbox and delivered by the owner’s consumer like any sent command. | One delivery path, one exactly-once mark. |
start() releases due timers in its outbox check; drain() releases only entries already due by the runtime clock. |
Deterministic tests: inject clock and move it. |
at is an ISO instant in UTC; the payload is validated against sends and may not hold sensitive fields. |
Same rules as a sent command. |
ATLAS027: a scheduled type must be declared in sends and its owner must give it access: 'internal'. |
A timer a caller could dispatch directly fires at will. |
Decisions never read a clock, but compute with instants through the pure time helpers: time.add(iso, ms), time.diff(from, to), and for local calendars with fixed UTC offsets in minutes time.localDate, time.localTime, time.fromLocal(date, 'HH:MM', offset), time.weekday(date) (ISO, Monday 1), time.addDays, time.minutes, time.hhmm, time.isDate, time.isTime.
Paged fan-out
Section titled “Paged fan-out”One fact that must touch many records reads one page of a view per transaction, then sends itself the next page:
.command('RemapProducts', { access: 'internal', input: t.object({ from: t.string, to: t.string, after: t.nullable(t.string) }), reads: { page: { view: 'products', where: (input) => q.eq('categoryId', input.from), order: [['productId', 'asc']], after: (input) => input.after, limit: 200 }, products: { from: 'products', key: (_, { state }): string[] => state.page.items.map((i: { productId: string }) => i.productId) }, }, decide({ input, state }, d) { const moved = Object.values(state.products).flatMap((p) => (p?.categoryId === input.from ? [p] : [])); // the record decides, not the page return d.accept({ write: moved.map((p) => d.write('products', p.productId, { ...p, categoryId: input.to })), send: state.page.next ? [d.send('RemapProducts', { ...input, after: state.page.next })] : [], // until next is null }); }, examples: [/* ... */],})- The page is eventually consistent and never version-checked; chain a record read on the items so a racing write conflicts.
- Page your own view: its projections run in your consumer, in order, so it already holds every fact before the trigger.
limitis fixed, 1..1000; the order must be served by an index (ATLAS023, ATLAS024); commands and reactions only.- Each page commits with its cursor advance; a crash resumes at the last committed page. Each page is one level of cause depth, so more pages than
maxDepthneed a larger one.