Skip to content

Commands

Commands and decisions

A command is a request to do something. It is declared on exactly one capability (ATLAS001), validated against its input shape, and decided by a pure function.

.command('CancelOrder', {
access: 'user',
input: t.object({ orderId: t.string, reason: t.string }),
reads: { order: { from: 'orders', key: (input) => input.orderId } },
decide({ input, state, actor }, d) {
const order = state.order;
if (order === null) return d.reject('order-not-found', { orderId: input.orderId });
// Ownership is checked against the record, not the actor: only the customer, staff or a policy (SYSTEM) may cancel.
if (order.customerId !== actor.id && !actor.roles.some((r) => r === 'admin' || r === 'system')) return d.reject('not-order-owner', { orderId: input.orderId });
if (order.status === 'cancelled') return d.reject('order-already-cancelled', { orderId: input.orderId });
const lines = order.lines.map(({ sku, quantity }) => ({ sku, quantity }));
return d.accept({
write: [d.write('orders', input.orderId, { ...order, status: 'cancelled', cancelReason: input.reason })],
emit: [d.fact('OrderCancelled', { orderId: input.orderId, reason: input.reason, lines })],
});
},
examples: [/* ... */],
})
  • A read’s key returns one key (state value T | null) or an array of keys (state value Record<key, T | null>), e.g. one stock row per order line.
  • A key receives the actor: key: (input, { actor }) => actor.id reads “my record” without an id in the payload.
  • Reads are chained: a key gets (trigger, { actor, state }), where state holds the reads declared before it. Returning null or [] reads nothing: request: { from: 'requests', key: (_, { state }) => state.user?.pendingId ?? null }. A key computed from state that returns many keys needs a return type: key: (_, { state }): string[] => ....
  • A foreign read (a collection another capability owns) declares a shape with only the fields it uses; atlas check verifies the owner provides them.
  • Every loaded record is version-checked at commit: a write to it in between is a conflict, and the decision runs again.

A read marked checked: false loads the record without the version check:

reads: {
stock: { from: 'stock', key: (input) => input.variantId, checked: false },
}

Use it only for an advisory check whose authority lives elsewhere, such as a stock pre-check at checkout while the reservation reaction decides for real. Never for a record the decision writes, or for a rule that must hold at commit (owner checks, limits). In a delivery batch an unchecked load stays out of the batch cache, so a checked read of the same record still expects its version.

d.accept({ ... }) takes:

Effect Helper Meaning
write d.write(collection, key, value) Upsert an owned record.
remove d.remove(collection, key) Delete an owned record atomically; it reads as null afterwards. Examples expect removes: ['drafts/d1'].
emit d.fact(type, payload) Append a fact to the outbox, in the same transaction.
send d.send(type, payload) Send a command to its owner, through the outbox.
schedule d.schedule(type, payload, at) Deliver a command later (see Reactions and timers).

Writing or removing a collection you do not own is refused (ATLAS017, and effects-invalid at runtime).

d.reject('order-not-found', { orderId }): a constant kebab-case code plus params; reason is the rendered form for logs (ATLAS015). Framework codes:

Code Meaning
unknown-command No capability handles the type.
invalid-input The input does not parse; the issue names the exact path.
unauthenticated / forbidden Access, checked before reads and decide.
conflict Retries exhausted after version conflicts; params { attempts }.
effects-invalid The effects break a rule (a collection not owned, a sensitive field in a fact).
decide-failed decide threw. Params are {}; the trace reason has the message.
store-rejected The database refuses these exact bytes (e.g. \u0000 in a string); params.code is the SQLSTATE. HTTP 422.
store-failed The store threw (connection lost). HTTP 503.
internal-error The adapter’s boundary: something else threw. HTTP 500.

A command may declare rejects: ['code', ...] for codes a shared helper produces that no example shows; they are merged into atlas schema, and ATLAS015 checks the file rejects with each.

examples: [
{
name: 'places a new order for the actor and computes the total',
actor: ada,
state: { existing: null },
input: { orderId: 'o1', lines: [{ sku: 'mug', quantity: 2, unitPriceCents: 900 }] },
expect: { emits: ['OrderPlaced'], writes: ['orders/o1'], facts: [{ type: 'OrderPlaced', customerId: 'c1', totalCents: 1800 }] },
},
{ name: 'rejects an anonymous caller', actor: null, input: { orderId: 'o1', lines: [] }, expect: { rejects: 'unauthenticated' } },
]
  • Example state is partial: missing reads default to null.
  • expect.rejects is the exact rejection code.
  • An example that sets actor (even null) enforces access as on dispatch; without actor it runs as SYSTEM and access is not exercised.
  • Every command needs an accepting example (ATLAS009 error) and should have a rejecting one (ATLAS009 warning).

Every read carries a version. A stale commit is retried by re-running the pure decision after a jittered exponential wait: createRuntime({ retry: { attempts: 5, baseMs: 10, maxMs: 200 } }) are the defaults since 0.5.0. The last conflict answers conflict { attempts }.