Skip to content

FactsCommands

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’s sends (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.

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.

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.
  • limit is 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 maxDepth need a larger one.